@forgesworn/fold-kit 0.5.1 → 0.6.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 +6 -0
- package/dist/epoch.d.ts +55 -3
- package/dist/epoch.js +83 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/member-epoch.d.ts +23 -2
- package/dist/member-epoch.js +41 -7
- package/llms.txt +1 -0
- package/package.json +1 -1
- package/vectors/member-epoch-vectors.json +64 -0
package/README.md
CHANGED
|
@@ -154,6 +154,12 @@ the rest).
|
|
|
154
154
|
`encodeMemberEpochRequest`, `decodeMemberEpochRequest`,
|
|
155
155
|
`encodeMemberEpochGrant`, `decodeMemberEpochGrant`, `readRekeyEvidence`,
|
|
156
156
|
`deriveMemberEpochRequestKey`. Vectors: `vectors/member-epoch-vectors.json`
|
|
157
|
+
- The known-members gate (#207, `docs/member-epoch-catch-up.md`): once a
|
|
158
|
+
room has removed anybody, both desks grant only to participants the room
|
|
159
|
+
knows (`known`), and send anybody else to approval (`onUnknown`; the
|
|
160
|
+
authority answers `refused: 'unknown'`, which `requestRoomEpoch` waits
|
|
161
|
+
through). `encodeRekeyEvent({ members })` and `hostRoomEpoch({ members })`
|
|
162
|
+
carry the authority's member list (`readMemberList` reads one)
|
|
157
163
|
- `canonicalAdmins`, `signAdmins`, `verifyAdmins` - the authority's signed
|
|
158
164
|
admin list
|
|
159
165
|
- `canonicalChannels`, `signChannels`, `verifyChannels`, `CHANNEL_NAME`,
|
package/dist/epoch.d.ts
CHANGED
|
@@ -73,6 +73,10 @@ export interface EpochKeys {
|
|
|
73
73
|
/** The cipher key. The room key in epoch 0. */
|
|
74
74
|
key: Uint8Array;
|
|
75
75
|
}
|
|
76
|
+
/** A participant list as a body carries it: lower-case, deduplicated and
|
|
77
|
+
* sorted, or undefined when the field is absent or is not a list of keys.
|
|
78
|
+
* Undefined is the safe reading, since it makes nobody known. */
|
|
79
|
+
export declare function readMemberList(raw: unknown): string[] | undefined;
|
|
76
80
|
/** A fresh secret for the next epoch. */
|
|
77
81
|
export declare function generateEpochSecret(): Uint8Array;
|
|
78
82
|
/**
|
|
@@ -108,6 +112,11 @@ export interface EncodeRekeyOptions {
|
|
|
108
112
|
* bring a device that missed this rekey up to date (see
|
|
109
113
|
* `member-epoch.ts`). Omitted, the event is byte-identical to before. */
|
|
110
114
|
commit?: boolean;
|
|
115
|
+
/** Every participant the authority knows to be in the room, not only the
|
|
116
|
+
* ones present now: a member who is offline as the room rekeys is still
|
|
117
|
+
* a member. Removed participants are dropped. Omitted, the event is
|
|
118
|
+
* byte-identical to before. */
|
|
119
|
+
members?: string[];
|
|
111
120
|
now: number;
|
|
112
121
|
/** A conference room's end, in unix seconds: the event carries it as a
|
|
113
122
|
* NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
|
|
@@ -125,6 +134,9 @@ export interface RekeyNotice {
|
|
|
125
134
|
* device that was removed, or that was not in the room when the
|
|
126
135
|
* authority rekeyed. */
|
|
127
136
|
secret?: Uint8Array;
|
|
137
|
+
/** The participants the authority listed as in the room. Absent from a
|
|
138
|
+
* rekey written without the list. */
|
|
139
|
+
members?: string[];
|
|
128
140
|
/** True for a current-state grant, whose removed list is cumulative.
|
|
129
141
|
* It synchronises this session; it does not describe a new removal. */
|
|
130
142
|
catchUp?: true;
|
|
@@ -225,8 +237,14 @@ export interface EpochRequest {
|
|
|
225
237
|
* cannot prove which participant it speaks for in this room, or from one
|
|
226
238
|
* that cannot prove it was admitted to the room at all. */
|
|
227
239
|
export declare function decodeEpochRequest(event: Event, opts: DecodeEpochRequestOptions): EpochRequest | null;
|
|
228
|
-
/**
|
|
229
|
-
|
|
240
|
+
/** How often a desk reports the same unknown participant while they keep
|
|
241
|
+
* asking: often enough that a missed "let them in?" comes back, rarely
|
|
242
|
+
* enough that it is not a nag. */
|
|
243
|
+
export declare const REPORT_UNKNOWN_EVERY_SECONDS = 60;
|
|
244
|
+
/** Why the authority would not hand an epoch over. `unknown` is not final:
|
|
245
|
+
* the room has removed somebody, and does not yet know this participant,
|
|
246
|
+
* so it waits for a member to let them in (#207). */
|
|
247
|
+
export type EpochRefusal = 'removed' | 'closed' | 'unknown';
|
|
230
248
|
export interface EncodeEpochGrantOptions {
|
|
231
249
|
roomId: string;
|
|
232
250
|
authoritySk: Uint8Array;
|
|
@@ -236,6 +254,9 @@ export interface EncodeEpochGrantOptions {
|
|
|
236
254
|
/** The current epoch. Omitted when refusing. */
|
|
237
255
|
epoch?: RoomEpoch;
|
|
238
256
|
removed?: string[];
|
|
257
|
+
/** The participants the authority knows, so the requester's own member
|
|
258
|
+
* desk knows them too. Omitted, the body is as before. */
|
|
259
|
+
members?: string[];
|
|
239
260
|
refused?: EpochRefusal;
|
|
240
261
|
/** A conference room's end, in unix seconds: the event carries it as a
|
|
241
262
|
* NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
|
|
@@ -261,6 +282,8 @@ export type EpochGrant = {
|
|
|
261
282
|
* the epochs it carried between the requester's and `epoch`. See
|
|
262
283
|
* `MemberEpochGrant.passed`. The authority's own answer has none. */
|
|
263
284
|
passed?: RoomEpoch[];
|
|
285
|
+
/** The participants the room knows, when the answer carried them. */
|
|
286
|
+
members?: string[];
|
|
264
287
|
refused?: undefined;
|
|
265
288
|
} | {
|
|
266
289
|
refused: EpochRefusal;
|
|
@@ -281,6 +304,24 @@ export interface HostRoomEpochOptions {
|
|
|
281
304
|
removed: () => ReadonlySet<string>;
|
|
282
305
|
/** True once the room has been closed: every request is refused. */
|
|
283
306
|
closed?: () => boolean;
|
|
307
|
+
/**
|
|
308
|
+
* Whether the room knows this participant: on the authority's member
|
|
309
|
+
* list, in the room now, or let in. Consulted only once somebody has been
|
|
310
|
+
* removed, and then everybody else is answered `unknown` (#207). Without
|
|
311
|
+
* it, after a removal, nobody is known. Asked on every request.
|
|
312
|
+
*/
|
|
313
|
+
known?: (participant: string) => boolean;
|
|
314
|
+
/** The participants to tell a granted device about, so its own member
|
|
315
|
+
* desk knows them too. Asked on every grant. */
|
|
316
|
+
members?: () => readonly string[];
|
|
317
|
+
/** Somebody the room does not know asked, after a removal: what an app
|
|
318
|
+
* turns into "let them in?". Called once per participant, and again
|
|
319
|
+
* every `reportUnknownEvery` seconds while they keep asking. Letting them in
|
|
320
|
+
* is making `known` say yes; the requester's next ask is then granted. */
|
|
321
|
+
onUnknown?: (request: EpochRequest) => void;
|
|
322
|
+
/** Seconds before a participant still unknown and still asking is
|
|
323
|
+
* reported again. Default 60. */
|
|
324
|
+
reportUnknownEvery?: number;
|
|
284
325
|
policy?: RoomPolicy;
|
|
285
326
|
legacyParticipants?: ReadonlySet<string>;
|
|
286
327
|
now?: () => number;
|
|
@@ -298,6 +339,13 @@ export interface HostRoomEpochOptions {
|
|
|
298
339
|
* *secret*, which opens epoch 0 and nothing after it. A request with no
|
|
299
340
|
* admission proof, or one made under some other key, is not answered at
|
|
300
341
|
* all: a stranger learns nothing, not even that a desk is here.
|
|
342
|
+
*
|
|
343
|
+
* The admission proof is made under the epoch-0 room key, which a removed
|
|
344
|
+
* member still holds, so removal by participant alone is undone by a fresh
|
|
345
|
+
* key (#207). Once anyone has been removed the desk therefore grants only
|
|
346
|
+
* to participants the room knows (`known`), and answers anybody else
|
|
347
|
+
* `unknown` until a member lets them in. A room that has never removed
|
|
348
|
+
* anybody is answered exactly as before.
|
|
301
349
|
*/
|
|
302
350
|
export declare function hostRoomEpoch(opts: HostRoomEpochOptions): {
|
|
303
351
|
close(): void;
|
|
@@ -319,6 +367,9 @@ export interface RequestRoomEpochOptions {
|
|
|
319
367
|
/** Ask the room's current members too (`memberEpochSource`): the first
|
|
320
368
|
* answer that checks out, the authority's or a member's, settles it. */
|
|
321
369
|
members?: MemberEpochSource;
|
|
370
|
+
/** The authority said the room does not know this participant yet. Not
|
|
371
|
+
* final: the ask goes on, and a member letting them in settles it. */
|
|
372
|
+
onUnknown?: () => void;
|
|
322
373
|
}
|
|
323
374
|
/** Thrown when the authority answered, and the answer was no. */
|
|
324
375
|
export declare class EpochRefusedError extends Error {
|
|
@@ -327,7 +378,8 @@ export declare class EpochRefusedError extends Error {
|
|
|
327
378
|
}
|
|
328
379
|
/** Ask the authority where the room is, and wait for the answer. Rejects
|
|
329
380
|
* with `EpochRefusedError` on a refusal, and with a plain error when
|
|
330
|
-
* nobody answers inside the timeout.
|
|
381
|
+
* nobody answers inside the timeout. `unknown` is not final: the ask goes
|
|
382
|
+
* on, and only a timeout after it rejects, with `EpochRefusedError`. */
|
|
331
383
|
export declare function requestRoomEpoch(opts: RequestRoomEpochOptions): Promise<Exclude<EpochGrant, {
|
|
332
384
|
refused: EpochRefusal;
|
|
333
385
|
}>>;
|
package/dist/epoch.js
CHANGED
|
@@ -80,6 +80,14 @@ function require32(bytes, what) {
|
|
|
80
80
|
if (bytes.length !== 32)
|
|
81
81
|
throw new Error(`${what} must be 32 bytes`);
|
|
82
82
|
}
|
|
83
|
+
/** A participant list as a body carries it: lower-case, deduplicated and
|
|
84
|
+
* sorted, or undefined when the field is absent or is not a list of keys.
|
|
85
|
+
* Undefined is the safe reading, since it makes nobody known. */
|
|
86
|
+
export function readMemberList(raw) {
|
|
87
|
+
if (!Array.isArray(raw) || !raw.every((p) => typeof p === 'string' && HEX64.test(p)))
|
|
88
|
+
return undefined;
|
|
89
|
+
return [...new Set(raw.map(normaliseHex))].sort();
|
|
90
|
+
}
|
|
83
91
|
function requireHex32(value, what) {
|
|
84
92
|
if (!HEX64.test(value))
|
|
85
93
|
throw new Error(`${what} must be 32-byte hex`);
|
|
@@ -119,6 +127,12 @@ export function deriveEpoch(epoch) {
|
|
|
119
127
|
key: hkdf(sha256, epoch.secret, undefined, `${EPOCH_KEY_INFO}/${n}`, 32),
|
|
120
128
|
};
|
|
121
129
|
}
|
|
130
|
+
function memberListOf(members, removed) {
|
|
131
|
+
const out = new Set(members.map((p) => requireHex32(p, 'member participant')));
|
|
132
|
+
for (const p of removed)
|
|
133
|
+
out.delete(normaliseHex(p));
|
|
134
|
+
return [...out].sort();
|
|
135
|
+
}
|
|
122
136
|
/** Announce the next epoch. See the module comment for what this is. */
|
|
123
137
|
export function encodeRekeyEvent(opts) {
|
|
124
138
|
require32(opts.authoritySk, 'authority secret key');
|
|
@@ -142,6 +156,7 @@ export function encodeRekeyEvent(opts) {
|
|
|
142
156
|
...(opts.by !== undefined ? { by: requireHex32(opts.by, 'admin') } : {}),
|
|
143
157
|
...(opts.closed ? { closed: true } : {}),
|
|
144
158
|
...(opts.commit ? { commit: epochCommitment(roomId, epoch, opts.next.secret) } : {}),
|
|
159
|
+
...(opts.members !== undefined ? { members: memberListOf(opts.members, removed) } : {}),
|
|
145
160
|
keys,
|
|
146
161
|
};
|
|
147
162
|
return finalizeEvent({
|
|
@@ -206,6 +221,9 @@ export function decodeRekeyEvent(event, opts) {
|
|
|
206
221
|
};
|
|
207
222
|
if (typeof body.by === 'string' && HEX64.test(body.by))
|
|
208
223
|
notice.by = normaliseHex(body.by);
|
|
224
|
+
const members = readMemberList(body.members);
|
|
225
|
+
if (members)
|
|
226
|
+
notice.members = members;
|
|
209
227
|
const device = getPublicKey(opts.deviceSk);
|
|
210
228
|
const mine = Object.entries(body.keys).find(([to]) => hexEquals(to, device))?.[1];
|
|
211
229
|
if (typeof mine === 'string') {
|
|
@@ -346,6 +364,10 @@ export function decodeEpochRequest(event, opts) {
|
|
|
346
364
|
return null;
|
|
347
365
|
}
|
|
348
366
|
}
|
|
367
|
+
/** How often a desk reports the same unknown participant while they keep
|
|
368
|
+
* asking: often enough that a missed "let them in?" comes back, rarely
|
|
369
|
+
* enough that it is not a nag. */
|
|
370
|
+
export const REPORT_UNKNOWN_EVERY_SECONDS = 60;
|
|
349
371
|
/** Answer one request: the current epoch sealed to the asking device, or a
|
|
350
372
|
* refusal it alone can read. */
|
|
351
373
|
export function encodeEpochGrant(opts) {
|
|
@@ -366,6 +388,8 @@ export function encodeEpochGrant(opts) {
|
|
|
366
388
|
body.secret = base64urlnopad.encode(opts.epoch.secret);
|
|
367
389
|
}
|
|
368
390
|
body.removed = [...new Set((opts.removed ?? []).map((p) => requireHex32(p, 'removed participant')))].sort();
|
|
391
|
+
if (opts.members !== undefined)
|
|
392
|
+
body.members = memberListOf(opts.members, body.removed);
|
|
369
393
|
}
|
|
370
394
|
return finalizeEvent({
|
|
371
395
|
kind: KINDS.EPOCH_GRANT,
|
|
@@ -399,21 +423,23 @@ export function decodeEpochGrant(event, opts) {
|
|
|
399
423
|
const body = JSON.parse(nip44.v2.decrypt(event.content, nip44.v2.utils.getConversationKey(opts.deviceSk, event.pubkey)));
|
|
400
424
|
if (body.v !== 1 || typeof body.request !== 'string' || !hexEquals(body.request, opts.request))
|
|
401
425
|
return null;
|
|
402
|
-
if (body.refused === 'removed' || body.refused === 'closed')
|
|
426
|
+
if (body.refused === 'removed' || body.refused === 'closed' || body.refused === 'unknown')
|
|
403
427
|
return { refused: body.refused };
|
|
404
428
|
if (!Number.isSafeInteger(body.epoch) || body.epoch < 0 || body.epoch > MAX_EPOCH)
|
|
405
429
|
return null;
|
|
406
430
|
const removed = Array.isArray(body.removed)
|
|
407
431
|
? [...new Set(body.removed.filter((p) => typeof p === 'string' && HEX64.test(p)).map(normaliseHex))].sort()
|
|
408
432
|
: [];
|
|
433
|
+
const members = readMemberList(body.members);
|
|
434
|
+
const listed = members ? { members } : {};
|
|
409
435
|
if (body.epoch === 0)
|
|
410
|
-
return { epoch: { epoch: 0 }, removed };
|
|
436
|
+
return { epoch: { epoch: 0 }, removed, ...listed };
|
|
411
437
|
if (typeof body.secret !== 'string')
|
|
412
438
|
return null;
|
|
413
439
|
const secret = base64urlnopad.decode(body.secret);
|
|
414
440
|
if (secret.length !== 32)
|
|
415
441
|
return null;
|
|
416
|
-
return { epoch: { epoch: body.epoch, secret }, removed };
|
|
442
|
+
return { epoch: { epoch: body.epoch, secret }, removed, ...listed };
|
|
417
443
|
}
|
|
418
444
|
catch {
|
|
419
445
|
return null;
|
|
@@ -428,6 +454,13 @@ export function decodeEpochGrant(event, opts) {
|
|
|
428
454
|
* *secret*, which opens epoch 0 and nothing after it. A request with no
|
|
429
455
|
* admission proof, or one made under some other key, is not answered at
|
|
430
456
|
* all: a stranger learns nothing, not even that a desk is here.
|
|
457
|
+
*
|
|
458
|
+
* The admission proof is made under the epoch-0 room key, which a removed
|
|
459
|
+
* member still holds, so removal by participant alone is undone by a fresh
|
|
460
|
+
* key (#207). Once anyone has been removed the desk therefore grants only
|
|
461
|
+
* to participants the room knows (`known`), and answers anybody else
|
|
462
|
+
* `unknown` until a member lets them in. A room that has never removed
|
|
463
|
+
* anybody is answered exactly as before.
|
|
431
464
|
*/
|
|
432
465
|
export function hostRoomEpoch(opts) {
|
|
433
466
|
require32(opts.authoritySk, 'authority secret key');
|
|
@@ -436,6 +469,16 @@ export function hostRoomEpoch(opts) {
|
|
|
436
469
|
const authority = getPublicKey(opts.authoritySk);
|
|
437
470
|
const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
|
|
438
471
|
const answered = new Set();
|
|
472
|
+
/** Requests answered `unknown`, which the requester sends again while it
|
|
473
|
+
* waits: answered again only once they are known. */
|
|
474
|
+
const waiting = new Set();
|
|
475
|
+
/** When each unknown participant was last reported through `onUnknown`,
|
|
476
|
+
* so one still waiting is reported again only after `reportUnknownEvery`. */
|
|
477
|
+
const reported = new Map();
|
|
478
|
+
const bound = (set) => {
|
|
479
|
+
if (set.size > 256)
|
|
480
|
+
set.delete(set.values().next().value);
|
|
481
|
+
};
|
|
439
482
|
let closed = false;
|
|
440
483
|
const unsub = opts.transport.subscribe([{ kinds: [KINDS.EPOCH_REQUEST], '#d': [roomId], '#p': [authority] }], (event) => {
|
|
441
484
|
if (closed)
|
|
@@ -450,14 +493,24 @@ export function hostRoomEpoch(opts) {
|
|
|
450
493
|
});
|
|
451
494
|
if (!request || answered.has(request.request))
|
|
452
495
|
return;
|
|
453
|
-
answered.add(request.request);
|
|
454
|
-
if (answered.size > 256)
|
|
455
|
-
answered.delete(answered.values().next().value);
|
|
456
496
|
let refused;
|
|
457
497
|
if (opts.closed?.())
|
|
458
498
|
refused = 'closed';
|
|
459
499
|
else if (opts.removed().has(request.participant))
|
|
460
500
|
refused = 'removed';
|
|
501
|
+
else if (opts.removed().size > 0 && !opts.known?.(request.participant))
|
|
502
|
+
refused = 'unknown';
|
|
503
|
+
if (refused === 'unknown') {
|
|
504
|
+
if (waiting.has(request.request))
|
|
505
|
+
return;
|
|
506
|
+
waiting.add(request.request);
|
|
507
|
+
bound(waiting);
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
waiting.delete(request.request);
|
|
511
|
+
answered.add(request.request);
|
|
512
|
+
bound(answered);
|
|
513
|
+
}
|
|
461
514
|
let grant;
|
|
462
515
|
try {
|
|
463
516
|
grant = refused
|
|
@@ -470,6 +523,7 @@ export function hostRoomEpoch(opts) {
|
|
|
470
523
|
now: now(),
|
|
471
524
|
epoch: opts.current(),
|
|
472
525
|
removed: [...opts.removed()],
|
|
526
|
+
...(opts.members ? { members: [...opts.members()] } : {}),
|
|
473
527
|
expiresAt: opts.expiresAt,
|
|
474
528
|
});
|
|
475
529
|
}
|
|
@@ -477,6 +531,14 @@ export function hostRoomEpoch(opts) {
|
|
|
477
531
|
return;
|
|
478
532
|
}
|
|
479
533
|
opts.transport.publish(grant).catch(() => { });
|
|
534
|
+
if (refused === 'unknown' && now() - (reported.get(request.participant) ?? -Infinity) >= (opts.reportUnknownEvery ?? REPORT_UNKNOWN_EVERY_SECONDS)) {
|
|
535
|
+
reported.set(request.participant, now());
|
|
536
|
+
if (reported.size > 256)
|
|
537
|
+
reported.delete(reported.keys().next().value);
|
|
538
|
+
opts.onUnknown?.(request);
|
|
539
|
+
}
|
|
540
|
+
if (!refused)
|
|
541
|
+
reported.delete(request.participant);
|
|
480
542
|
if (refused)
|
|
481
543
|
opts.onRefused?.(request, refused);
|
|
482
544
|
else
|
|
@@ -495,14 +557,19 @@ export function hostRoomEpoch(opts) {
|
|
|
495
557
|
export class EpochRefusedError extends Error {
|
|
496
558
|
refused;
|
|
497
559
|
constructor(refused) {
|
|
498
|
-
super(refused === 'removed'
|
|
560
|
+
super(refused === 'removed'
|
|
561
|
+
? 'you were removed from this room'
|
|
562
|
+
: refused === 'unknown'
|
|
563
|
+
? 'nobody in this room has let you in yet'
|
|
564
|
+
: 'this room has been closed');
|
|
499
565
|
this.name = 'EpochRefusedError';
|
|
500
566
|
this.refused = refused;
|
|
501
567
|
}
|
|
502
568
|
}
|
|
503
569
|
/** Ask the authority where the room is, and wait for the answer. Rejects
|
|
504
570
|
* with `EpochRefusedError` on a refusal, and with a plain error when
|
|
505
|
-
* nobody answers inside the timeout.
|
|
571
|
+
* nobody answers inside the timeout. `unknown` is not final: the ask goes
|
|
572
|
+
* on, and only a timeout after it rejects, with `EpochRefusedError`. */
|
|
506
573
|
export function requestRoomEpoch(opts) {
|
|
507
574
|
const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
|
|
508
575
|
const roomId = requireHex32(opts.roomId, 'room id');
|
|
@@ -523,6 +590,7 @@ export function requestRoomEpoch(opts) {
|
|
|
523
590
|
let expiry;
|
|
524
591
|
let unsub = () => { };
|
|
525
592
|
let stopMembers = () => { };
|
|
593
|
+
let unknown = false;
|
|
526
594
|
const finish = (settle) => {
|
|
527
595
|
if (settled)
|
|
528
596
|
return;
|
|
@@ -539,7 +607,12 @@ export function requestRoomEpoch(opts) {
|
|
|
539
607
|
const grant = decodeEpochGrant(event, { roomId, authority: opts.authority, deviceSk: opts.deviceSk, request: request.id, now: now() });
|
|
540
608
|
if (!grant)
|
|
541
609
|
return;
|
|
542
|
-
if (grant.refused)
|
|
610
|
+
if (grant.refused === 'unknown') {
|
|
611
|
+
if (!unknown)
|
|
612
|
+
opts.onUnknown?.();
|
|
613
|
+
unknown = true;
|
|
614
|
+
}
|
|
615
|
+
else if (grant.refused)
|
|
543
616
|
finish(() => reject(new EpochRefusedError(grant.refused)));
|
|
544
617
|
else
|
|
545
618
|
finish(() => resolve(grant));
|
|
@@ -555,7 +628,7 @@ export function requestRoomEpoch(opts) {
|
|
|
555
628
|
if (!settled)
|
|
556
629
|
opts.transport.publish(request).catch(() => { });
|
|
557
630
|
};
|
|
558
|
-
expiry = setTimeout(() => finish(() => reject(new Error('the room has moved to a newer epoch and its keeper is not answering'))), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
631
|
+
expiry = setTimeout(() => finish(() => reject(unknown ? new EpochRefusedError('unknown') : new Error('the room has moved to a newer epoch and its keeper is not answering'))), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
559
632
|
expiry.unref?.();
|
|
560
633
|
retry = setInterval(ask, opts.retryMs ?? DEFAULT_RETRY_MS);
|
|
561
634
|
retry.unref?.();
|
package/dist/index.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } fr
|
|
|
24
24
|
export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
|
|
25
25
|
export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
|
|
26
26
|
export type { RoomLink } from './link.js';
|
|
27
|
-
export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, decodeRekeyEvent, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
|
|
27
|
+
export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
|
|
28
28
|
export type { RoomEpoch, EpochKeys, EncodeRekeyOptions, RekeyNotice, PeekRekeyOptions, DecodeRekeyOptions, EpochRequestAdmissionOptions, EncodeEpochRequestOptions, DecodeEpochRequestOptions, EpochRequest, EpochRefusal, EncodeEpochGrantOptions, DecodeEpochGrantOptions, EpochGrant, HostRoomEpochOptions, RequestRoomEpochOptions, SignAdminsOptions, SignChannelsOptions, VerifyChannelsOptions, VerifyAdminsOptions, } from './epoch.js';
|
|
29
29
|
export { EPOCH_COMMIT_PREFIX, epochCommitment, EPOCH_COMMIT_LABELS } from './epoch-commit.js';
|
|
30
30
|
export { MEMBER_EPOCH_KINDS, MEMBER_EPOCH_REQUEST_KEY_INFO, MAX_MEMBER_EPOCH_CHAIN, deriveMemberEpochRequestKey, readRekeyEvidence, encodeMemberEpochRequest, decodeMemberEpochRequest, encodeMemberEpochGrant, decodeMemberEpochGrant, hostMemberEpochDesk, memberEpochSource, requestMemberEpoch, MEMBER_EPOCH_LABELS, } from './member-epoch.js';
|
package/dist/index.js
CHANGED
|
@@ -16,7 +16,7 @@ export { encodePersistentInvitation, decodePersistentInvitation, requestPersiste
|
|
|
16
16
|
export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
|
|
17
17
|
export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
|
|
18
18
|
export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
|
|
19
|
-
export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, decodeRekeyEvent, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
|
|
19
|
+
export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
|
|
20
20
|
export { EPOCH_COMMIT_PREFIX, epochCommitment, EPOCH_COMMIT_LABELS } from './epoch-commit.js';
|
|
21
21
|
export { MEMBER_EPOCH_KINDS, MEMBER_EPOCH_REQUEST_KEY_INFO, MAX_MEMBER_EPOCH_CHAIN, deriveMemberEpochRequestKey, readRekeyEvidence, encodeMemberEpochRequest, decodeMemberEpochRequest, encodeMemberEpochGrant, decodeMemberEpochGrant, hostMemberEpochDesk, memberEpochSource, requestMemberEpoch, MEMBER_EPOCH_LABELS, } from './member-epoch.js';
|
|
22
22
|
export { deriveChannel, CHANNEL_ID_INFO, CHANNEL_KEY_INFO, MAX_CHANNEL_NAME_LENGTH, CHANNEL_LABELS } from './channel.js';
|
package/dist/member-epoch.d.ts
CHANGED
|
@@ -61,6 +61,8 @@ export interface RekeyEvidence {
|
|
|
61
61
|
closed: boolean;
|
|
62
62
|
/** The epoch commitment, when the authority wrote one. */
|
|
63
63
|
commit?: string;
|
|
64
|
+
/** The authority's member list, when it wrote one. */
|
|
65
|
+
members?: string[];
|
|
64
66
|
}
|
|
65
67
|
/**
|
|
66
68
|
* Read an authority-signed rekey with the key of the epoch it leaves,
|
|
@@ -161,6 +163,8 @@ export interface MemberEpochGrant {
|
|
|
161
163
|
* grant one epoch ahead. Kept, they let the requester read what was said
|
|
162
164
|
* in the epochs it skipped, and hand them on from its own member desk. */
|
|
163
165
|
passed: RoomEpoch[];
|
|
166
|
+
/** The newest member list in the chain, when any rekey in it had one. */
|
|
167
|
+
members?: string[];
|
|
164
168
|
}
|
|
165
169
|
/**
|
|
166
170
|
* Verify a member's answer to one of this device's member requests. Null
|
|
@@ -190,6 +194,21 @@ export interface HostMemberEpochDeskOptions {
|
|
|
190
194
|
removed: () => ReadonlySet<string>;
|
|
191
195
|
/** True once the room has been closed. */
|
|
192
196
|
closed?: () => boolean;
|
|
197
|
+
/**
|
|
198
|
+
* Whether the room knows this participant: on the authority's latest
|
|
199
|
+
* member list, in the room's current roster, or let in from this device.
|
|
200
|
+
* Consulted only once somebody has been removed; then nobody else is
|
|
201
|
+
* answered (#207). Without it, after a removal, nobody is known.
|
|
202
|
+
*/
|
|
203
|
+
known?: (participant: string) => boolean;
|
|
204
|
+
/** Somebody the room does not know asked, after a removal. Called once
|
|
205
|
+
* per participant, and again every `reportUnknownEvery` seconds while
|
|
206
|
+
* they keep asking; nothing is published. Letting them in is making `known`
|
|
207
|
+
* say yes, and their next ask is answered. */
|
|
208
|
+
onUnknown?: (request: MemberEpochRequest) => void;
|
|
209
|
+
/** Seconds before a participant still unknown and still asking is
|
|
210
|
+
* reported again. Default 60 (`REPORT_UNKNOWN_EVERY_SECONDS`). */
|
|
211
|
+
reportUnknownEvery?: number;
|
|
193
212
|
policy?: RoomPolicy;
|
|
194
213
|
now?: () => number;
|
|
195
214
|
/** Upper bound of the random wait before answering, so several members do
|
|
@@ -212,8 +231,10 @@ export interface HostMemberEpochDeskOptions {
|
|
|
212
231
|
* Answer member epoch requests for as long as the handle is open: what any
|
|
213
232
|
* member at the current epoch runs beside its session. It answers only an
|
|
214
233
|
* admitted, credentialled device that is not removed, in a room that is not
|
|
215
|
-
* closed,
|
|
216
|
-
*
|
|
234
|
+
* closed, from a participant the room knows once anybody has been removed
|
|
235
|
+
* (see `hostRoomEpoch` for why), and only when it can hand over the whole
|
|
236
|
+
* chain from the requester's epoch to its own with the authority's rekeys
|
|
237
|
+
* to prove it.
|
|
217
238
|
*
|
|
218
239
|
* Anti-amplification: each answer waits a random `[0, jitterMs)`, and is
|
|
219
240
|
* dropped if another member's grant to the same device appears meanwhile.
|
package/dist/member-epoch.js
CHANGED
|
@@ -10,7 +10,7 @@ import { verifyEventUncached } from './verify.js';
|
|
|
10
10
|
import { withExpiration } from './expiration.js';
|
|
11
11
|
import { epochCommitment } from './epoch-commit.js';
|
|
12
12
|
import { KINDS } from './kinds.js';
|
|
13
|
-
import { MAX_EPOCH, deriveEpoch, epochRequestAdmission, peekRekeyEvent, } from './epoch.js';
|
|
13
|
+
import { MAX_EPOCH, REPORT_UNKNOWN_EVERY_SECONDS, deriveEpoch, epochRequestAdmission, peekRekeyEvent, readMemberList, } from './epoch.js';
|
|
14
14
|
/**
|
|
15
15
|
* Member-to-member epoch catch-up: any current member can bring an
|
|
16
16
|
* admitted, non-removed member up to date.
|
|
@@ -118,6 +118,9 @@ export function readRekeyEvidence(event, opts) {
|
|
|
118
118
|
};
|
|
119
119
|
if (typeof body.commit === 'string' && HEX64.test(body.commit))
|
|
120
120
|
evidence.commit = normaliseHex(body.commit);
|
|
121
|
+
const members = readMemberList(body.members);
|
|
122
|
+
if (members)
|
|
123
|
+
evidence.members = members;
|
|
121
124
|
return evidence;
|
|
122
125
|
}
|
|
123
126
|
catch {
|
|
@@ -279,6 +282,7 @@ export function decodeMemberEpochGrant(event, opts) {
|
|
|
279
282
|
let previous = opts.current;
|
|
280
283
|
let secret;
|
|
281
284
|
const passed = [];
|
|
285
|
+
let members;
|
|
282
286
|
for (let i = 0; i < length; i += 1) {
|
|
283
287
|
const evidence = readRekeyEvidence(body.rekeys[i], { roomId, authority: opts.authority, previous });
|
|
284
288
|
if (!evidence)
|
|
@@ -289,6 +293,8 @@ export function decodeMemberEpochGrant(event, opts) {
|
|
|
289
293
|
return null;
|
|
290
294
|
for (const p of evidence.removed)
|
|
291
295
|
removed.add(p);
|
|
296
|
+
if (evidence.members)
|
|
297
|
+
members = evidence.members;
|
|
292
298
|
const raw = body.secrets[i];
|
|
293
299
|
if (typeof raw !== 'string')
|
|
294
300
|
return null;
|
|
@@ -308,7 +314,7 @@ export function decodeMemberEpochGrant(event, opts) {
|
|
|
308
314
|
if (i < length - 1)
|
|
309
315
|
passed.push({ epoch: evidence.epoch, secret });
|
|
310
316
|
}
|
|
311
|
-
return { epoch: { epoch: top, secret: secret }, removed: [...removed].sort(), passed };
|
|
317
|
+
return { epoch: { epoch: top, secret: secret }, removed: [...removed].sort(), passed, ...(members ? { members } : {}) };
|
|
312
318
|
}
|
|
313
319
|
catch {
|
|
314
320
|
return null;
|
|
@@ -318,8 +324,10 @@ export function decodeMemberEpochGrant(event, opts) {
|
|
|
318
324
|
* Answer member epoch requests for as long as the handle is open: what any
|
|
319
325
|
* member at the current epoch runs beside its session. It answers only an
|
|
320
326
|
* admitted, credentialled device that is not removed, in a room that is not
|
|
321
|
-
* closed,
|
|
322
|
-
*
|
|
327
|
+
* closed, from a participant the room knows once anybody has been removed
|
|
328
|
+
* (see `hostRoomEpoch` for why), and only when it can hand over the whole
|
|
329
|
+
* chain from the requester's epoch to its own with the authority's rekeys
|
|
330
|
+
* to prove it.
|
|
323
331
|
*
|
|
324
332
|
* Anti-amplification: each answer waits a random `[0, jitterMs)`, and is
|
|
325
333
|
* dropped if another member's grant to the same device appears meanwhile.
|
|
@@ -344,6 +352,10 @@ export function hostMemberEpochDesk(opts) {
|
|
|
344
352
|
const grantSeen = new Set();
|
|
345
353
|
/** Requesting devices this desk has already stood down for once. */
|
|
346
354
|
const stoodDown = new Set();
|
|
355
|
+
/** When each unknown participant was last reported. A requester asks
|
|
356
|
+
* afresh every `retryMs` while it waits, so this is by participant, not
|
|
357
|
+
* by request: one "let them in?" a `reportUnknownEvery`, not one a retry. */
|
|
358
|
+
const reported = new Map();
|
|
347
359
|
/** Ids of the grants this desk published: each is signed by a one-time
|
|
348
360
|
* key, so its own grants are told apart by id, not by signer. */
|
|
349
361
|
const mine = new Set();
|
|
@@ -386,6 +398,12 @@ export function hostMemberEpochDesk(opts) {
|
|
|
386
398
|
}
|
|
387
399
|
return { epochs, rekeys };
|
|
388
400
|
};
|
|
401
|
+
const admissible = (request) => {
|
|
402
|
+
const removed = opts.removed();
|
|
403
|
+
if ([...removed].some((p) => hexEquals(p, request.participant)))
|
|
404
|
+
return false;
|
|
405
|
+
return removed.size === 0 || opts.known?.(request.participant) === true;
|
|
406
|
+
};
|
|
389
407
|
const answer = (request) => {
|
|
390
408
|
if (closed)
|
|
391
409
|
return;
|
|
@@ -398,7 +416,7 @@ export function hostMemberEpochDesk(opts) {
|
|
|
398
416
|
// Asked again, now that the wait is over: the room may have moved.
|
|
399
417
|
if (opts.closed?.())
|
|
400
418
|
return;
|
|
401
|
-
if (
|
|
419
|
+
if (!admissible(request))
|
|
402
420
|
return;
|
|
403
421
|
const current = opts.current();
|
|
404
422
|
if (!current || request.have >= current.epoch)
|
|
@@ -443,16 +461,32 @@ export function hostMemberEpochDesk(opts) {
|
|
|
443
461
|
const request = decodeMemberEpochRequest(event, { roomId, authority, roomKey: opts.roomKey, now: now(), policy: opts.policy });
|
|
444
462
|
if (!request || answered.has(request.request))
|
|
445
463
|
return;
|
|
446
|
-
answered.add(request.request);
|
|
447
|
-
bound(answered);
|
|
448
464
|
if (opts.closed?.()) {
|
|
465
|
+
answered.add(request.request);
|
|
466
|
+
bound(answered);
|
|
449
467
|
opts.onRefused?.(request, 'closed');
|
|
450
468
|
return;
|
|
451
469
|
}
|
|
452
470
|
if ([...opts.removed()].some((p) => hexEquals(p, request.participant))) {
|
|
471
|
+
answered.add(request.request);
|
|
472
|
+
bound(answered);
|
|
453
473
|
opts.onRefused?.(request, 'removed');
|
|
454
474
|
return;
|
|
455
475
|
}
|
|
476
|
+
if (!admissible(request)) {
|
|
477
|
+
const last = reported.get(request.participant);
|
|
478
|
+
if (last !== undefined && now() - last < (opts.reportUnknownEvery ?? REPORT_UNKNOWN_EVERY_SECONDS))
|
|
479
|
+
return;
|
|
480
|
+
reported.set(request.participant, now());
|
|
481
|
+
if (reported.size > 256)
|
|
482
|
+
reported.delete(reported.keys().next().value);
|
|
483
|
+
opts.onUnknown?.(request);
|
|
484
|
+
opts.onRefused?.(request, 'unknown');
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
reported.delete(request.participant);
|
|
488
|
+
answered.add(request.request);
|
|
489
|
+
bound(answered);
|
|
456
490
|
const current = opts.current();
|
|
457
491
|
if (!current || request.have >= current.epoch)
|
|
458
492
|
return;
|
package/llms.txt
CHANGED
|
@@ -80,6 +80,7 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
|
|
|
80
80
|
- `epochRequestAdmission(opts)`, `deriveEpochRequestKey(roomKey)`
|
|
81
81
|
- `hostRoomEpoch(opts)` / `requestRoomEpoch(opts)` / `EpochRefusedError` - the live request/grant desk, both sides
|
|
82
82
|
- Member epoch catch-up (see `docs/member-epoch-catch-up.md`): `encodeRekeyEvent({ ..., commit: true })` writes `epochCommitment(roomId, epoch, secret)` into the rekey body; `hostMemberEpochDesk(opts)` lets any current member answer; `requestRoomEpoch({ ..., members: memberEpochSource(opts) })` asks the authority and members, first verifiable answer wins; `requestMemberEpoch(opts)` asks members alone. Kinds `MEMBER_EPOCH_KINDS` = `{ REQUEST: 20471, GRANT: 20472 }`. A member grant carries the authority-signed rekeys from the asker's epoch to the current one; the asker verifies the chain itself (`decodeMemberEpochGrant`) and refuses a last epoch with no commitment (legacy: authority only), a chain that removes it or closes the room, and one that stops short of `expected`. A verified grant is `{ epoch, removed, passed }`: `passed` holds the epochs between the asker's and `epoch`, oldest first, and `requestRoomEpoch` carries it as an optional `passed` when a member answered. Codecs: `encodeMemberEpochRequest`, `decodeMemberEpochRequest`, `encodeMemberEpochGrant`, `readRekeyEvidence`, `deriveMemberEpochRequestKey`, `MAX_MEMBER_EPOCH_CHAIN` (32)
|
|
83
|
+
- The known-members gate (#207, see `docs/member-epoch-catch-up.md`): once a room has removed anybody (`removed()` not empty), `hostRoomEpoch` and `hostMemberEpochDesk` grant only to a participant for which `known(participant)` is true; anybody else is reported through `onUnknown` (once per participant, and again every `reportUnknownEvery` seconds, default `REPORT_UNKNOWN_EVERY_SECONDS` = 60, while they keep asking) and refused: the authority answers `refused: 'unknown'` (an `EpochRefusal`), a member stays silent. `unknown` is not final: `requestRoomEpoch` keeps asking, calls its `onUnknown` once, and rejects with `EpochRefusedError('unknown')` only at its timeout; a desk looks at the requester's next ask afresh, so letting somebody in is making `known` say yes. No `known` after a removal means nobody is known. Member lists: `encodeRekeyEvent({ members })` writes the authority's list (removed dropped, sorted) into the body, read back as `RekeyNotice.members` and `RekeyEvidence.members`, and as `MemberEpochGrant.members` (newest in the chain); `hostRoomEpoch({ members })` puts it in its grants (`EpochGrant.members`). `readMemberList(raw)` reads one, undefined when malformed. Without `members` and with no removal every event is byte-identical to 0.5.1
|
|
83
84
|
- `canonicalAdmins(admins)` / `signAdmins(opts)` / `verifyAdmins(opts)` - the authority's signed admin list
|
|
84
85
|
- `canonicalChannels(channels)` / `signChannels(opts)` / `verifyChannels(opts)` - the authority's signed channel list; `CHANNEL_NAME`, `RESERVED_CHANNELS`
|
|
85
86
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgesworn/fold-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Circle primitives for private Nostr groups nobody operates: epoch keys, link invites, removal by rekey, device credentials.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -136,6 +136,70 @@
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
},
|
|
139
|
+
{
|
|
140
|
+
"name": "rekey-with-members",
|
|
141
|
+
"kind": "positive",
|
|
142
|
+
"note": "The rekey to epoch 2 with the authority's member list (`encodeRekeyEvent({ members })`, #207): the participants it knows to be in the room, the removed ones dropped even when passed in, lower-case, deduplicated and sorted. Body key order: v, epoch, removed, commit, members, keys. A desk hands an epoch on, once anybody has been removed, only to a participant the room knows; this list is where a member learns who that is. A list that is not an array of 32-byte hex keys is read as absent, which makes nobody known.",
|
|
143
|
+
"input": {
|
|
144
|
+
"roomId": "76c7081ef2322b7619d5cb5e690779fb942d63964f0b819e3d852db6c6a264f9",
|
|
145
|
+
"authority": "a5098de007349f8c12d6a854dfc069074b803470f59484697c50c5211b13c810",
|
|
146
|
+
"previousEpoch": 1,
|
|
147
|
+
"previousSecretHex": "cf6c5602daa51ecab29fbcbcef0da517b303cd59ee2904c15e226b7dd00e7752",
|
|
148
|
+
"previousKeyHex": "e038a7fca65628eebee35c562410ac63cce191b776ba0e8e5a1d241d9417f733",
|
|
149
|
+
"next": {
|
|
150
|
+
"epoch": 2,
|
|
151
|
+
"secretHex": "d4f5a0426e6880797fa29d914bb6977d193dfcd4f46b2a3acc576226eeec3ac9"
|
|
152
|
+
},
|
|
153
|
+
"recipients": [
|
|
154
|
+
"ef4396c842882c47ad1106d846d5c7e712c8928b69c92bc1798fa63deb9dd76b"
|
|
155
|
+
],
|
|
156
|
+
"removed": [
|
|
157
|
+
"298cf780bc6d1b93c4900b20c89a911286ee3488ab94f543d96d567c70eb9ed2"
|
|
158
|
+
],
|
|
159
|
+
"members": [
|
|
160
|
+
"588c59130ea8b9d81e836d0c5a89daa6b888923357c1415359b48595b4937f53",
|
|
161
|
+
"298cf780bc6d1b93c4900b20c89a911286ee3488ab94f543d96d567c70eb9ed2"
|
|
162
|
+
],
|
|
163
|
+
"authoritySkHex": "825cf62829b0262bc5b46870d5cc054d6049e54a68cf1ea393c3da56226c456b",
|
|
164
|
+
"createdAt": 1799999002,
|
|
165
|
+
"event": {
|
|
166
|
+
"id": "d8d5c810c2e22d87493d8a10f908e38e68b5e4611ad5a230103ca01d5ba7a954",
|
|
167
|
+
"pubkey": "a5098de007349f8c12d6a854dfc069074b803470f59484697c50c5211b13c810",
|
|
168
|
+
"created_at": 1799999002,
|
|
169
|
+
"kind": 1462,
|
|
170
|
+
"tags": [
|
|
171
|
+
[
|
|
172
|
+
"d",
|
|
173
|
+
"76c7081ef2322b7619d5cb5e690779fb942d63964f0b819e3d852db6c6a264f9"
|
|
174
|
+
],
|
|
175
|
+
[
|
|
176
|
+
"epoch",
|
|
177
|
+
"2"
|
|
178
|
+
]
|
|
179
|
+
],
|
|
180
|
+
"content": "AjCp1V9xfa32TYx5FAmMp1toqAM2yKrdBWdygJs+bFNGNIls48ZUufb0m3Cs51qusvmVJF51AEvXiW82LyhO6o/2J9Sra65b80+cyj8Km+LrLCXx6qyLPIYjKdISORDS6UhDKXa9UInbnZnGf87KKruDujB82QKDo26ImrXPbzy246dYMCvon7dYLGa+y3Qk9u5OLVKSrYVhDhFRHZX2XMltl478SRQ7GuvzbibZbFrNz1EvWSJOqbxf8YLpvYzbq55xmr9CA1rjk9vBKTzaHGX23ZvPPdaQFHh53YXCzYGN4+p54P/gH9PgZREZt+5Jel5kDhVfk8YMinaSzaBQ7MvEUEEIGDUPc6xbmZ3twlam+y/4H0vuugQtOm85tU7sozohPj65hlpaxRmTdenWivldIAWyPAEsUjOtGcvnFur5Hbg9H+WEsyXAX6tLZZV3jNFcCPLkoWB9O3iFasN3m2P0larhRTr9lhReqzt3UxtGBp14rErj0uZEZlwrz4n3ZiFgH/qc1X/pFlskh4dQJV/PGCEe6F7v7fd4aChiYHA1ghq2cy4fKW44+FU2EKPEezWcD96sOshiCoh0xWxHJoNbWhKJ5P8EcyK7TCH9UJVk9OyAiGVYnnxh+cuDgVfCOdRR6VbxWopqclvhlV+NGBullxSv5aQJSj6fS5O+QxSCwZrR8DA2SMKRSFU+kXHQxOgEvZW1cPdHWMtUr1dwsRrMd3ayNyZqSIjjyktqzsmrD+Pl6Ikk5tbE3kyNYdWHX7Mz",
|
|
181
|
+
"sig": "90c5666a5ca4c5d4c1a0e4b3b4422aa7462d4300986f82afff435a88edb48a41e3191192c6404b17401712204de56ba2269a6b4376f0ef224ad9798728550710"
|
|
182
|
+
},
|
|
183
|
+
"randomHex": [
|
|
184
|
+
"ff9c267263a44961a1d49a09c831ef35fa2d53e4d4cfc572d72f506478e61045",
|
|
185
|
+
"30a9d55f717dadf64d8c7914098ca75b68a80336c8aadd056772809b3e6c5346",
|
|
186
|
+
"11ae6ba1ae1e25f1493588462e919538089a63c28e68b7f5734bd0df2ba395e3"
|
|
187
|
+
]
|
|
188
|
+
},
|
|
189
|
+
"output": {
|
|
190
|
+
"evidence": {
|
|
191
|
+
"epoch": 2,
|
|
192
|
+
"removed": [
|
|
193
|
+
"298cf780bc6d1b93c4900b20c89a911286ee3488ab94f543d96d567c70eb9ed2"
|
|
194
|
+
],
|
|
195
|
+
"closed": false,
|
|
196
|
+
"commit": "3472885df839fc5d48d809476e28e36c458c3e4717e35c2ec0d69bbbdf94f896",
|
|
197
|
+
"members": [
|
|
198
|
+
"588c59130ea8b9d81e836d0c5a89daa6b888923357c1415359b48595b4937f53"
|
|
199
|
+
]
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
},
|
|
139
203
|
{
|
|
140
204
|
"name": "member-request",
|
|
141
205
|
"kind": "positive",
|