@forgesworn/fold-kit 0.8.0 → 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 CHANGED
@@ -126,6 +126,16 @@ the rest).
126
126
  `requestPersistentRoomAdmission` keeps the relays of the newest signed
127
127
  copy that names any. `isInvitationRelays`, `requireInvitationRelays` and
128
128
  `MAX_INVITATION_RELAYS` are the rule
129
+ - Self-destructing rooms (`docs/room-destruct.md`): pass `destruct: true`
130
+ and the encrypted body carries it after `ends` (no end needed: the room
131
+ then self-destructs when its authority closes it). The decoder returns
132
+ `destruct: true` and refuses the envelope on any other value;
133
+ `requestPersistentRoomAdmission` keeps it if any signed copy says it.
134
+ `encodeInvitationRetirement({ ended: true, destruct: true })` and
135
+ `encodeRekeyEvent({ closed: true, destruct: true })` carry it when the
136
+ room ends early, read back by `decodeInvitationRetirementNotice`,
137
+ `RekeyNotice.destruct` and `RekeyEvidence.destruct`. Vectors:
138
+ `vectors/destruct-vectors.json`
129
139
  - `withExpiration`, `isRoomEnds`, `requireRoomEnds`, `MAX_ROOM_ENDS_SECONDS` -
130
140
  the conference-room expiration rule: add the end as an `expiration` tag,
131
141
  keep an earlier one, lower a later one. `encodeInvitationRetirement` takes
package/dist/epoch.d.ts CHANGED
@@ -111,6 +111,10 @@ export interface EncodeRekeyOptions {
111
111
  /** True when the room is being closed: nobody is kept, and the event
112
112
  * says so rather than leaving everybody to wonder. */
113
113
  closed?: boolean;
114
+ /** The room being closed self-destructs: every device deletes what it
115
+ * wrote and forgets the room. Inside the encrypted body. Refused
116
+ * without `closed`. Omitted, the event is byte-identical to before. */
117
+ destruct?: boolean;
114
118
  /** Mark this as a scheduled turn of the key rather than a removal, so
115
119
  * clients can let it pass quietly. Refused with a removal or a close: a
116
120
  * rekey that removes somebody is never quiet. Omitted, the event is
@@ -144,6 +148,10 @@ export interface RekeyNotice {
144
148
  removed: string[];
145
149
  by?: string;
146
150
  closed: boolean;
151
+ /** True when the closed room self-destructs. Absent unless `closed`:
152
+ * beside an open room the flag is not believed, and the rekey is read
153
+ * as if it were not there. */
154
+ destruct?: true;
147
155
  /** True for a scheduled turn of the key: nobody removed, the room still
148
156
  * open. What lets a client move on without saying so. Absent when the
149
157
  * body contradicts itself, so a removal is always announced. */
package/dist/epoch.js CHANGED
@@ -145,6 +145,8 @@ export function encodeRekeyEvent(opts) {
145
145
  const removed = [...new Set(opts.removed.map((p) => requireHex32(p, 'removed participant')))].sort();
146
146
  if (opts.scheduled && (removed.length > 0 || opts.closed))
147
147
  throw new Error('a scheduled rekey removes nobody and does not close the room');
148
+ if (opts.destruct && !opts.closed)
149
+ throw new Error('only a closing rekey can self-destruct the room');
148
150
  const keys = {};
149
151
  const sealed = { v: 1, secret: base64urlnopad.encode(opts.next.secret) };
150
152
  const plaintext = JSON.stringify(sealed);
@@ -159,6 +161,7 @@ export function encodeRekeyEvent(opts) {
159
161
  removed,
160
162
  ...(opts.by !== undefined ? { by: requireHex32(opts.by, 'admin') } : {}),
161
163
  ...(opts.closed ? { closed: true } : {}),
164
+ ...(opts.destruct ? { destruct: true } : {}),
162
165
  ...(opts.scheduled ? { scheduled: true } : {}),
163
166
  ...(opts.commit ? { commit: epochCommitment(roomId, epoch, opts.next.secret) } : {}),
164
167
  ...(opts.members !== undefined ? { members: memberListOf(opts.members, removed) } : {}),
@@ -226,6 +229,8 @@ export function decodeRekeyEvent(event, opts) {
226
229
  };
227
230
  if (typeof body.by === 'string' && HEX64.test(body.by))
228
231
  notice.by = normaliseHex(body.by);
232
+ if (body.destruct === true && notice.closed)
233
+ notice.destruct = true;
229
234
  if (body.scheduled === true && notice.removed.length === 0 && !notice.closed)
230
235
  notice.scheduled = true;
231
236
  const members = readMemberList(body.members);
@@ -165,6 +165,12 @@ export interface EncodeInvitationRetirementOptions {
165
165
  /** The room itself was ended, not just this link replaced. Additive: a
166
166
  * reader that predates it still sees an ordinary retirement. */
167
167
  ended?: boolean;
168
+ /** The ended room self-destructs: every device deletes what it wrote and
169
+ * forgets the room. Only beside `ended`, else it throws. Unlike the
170
+ * invitation's own flag this content is not encrypted: it says no more
171
+ * than `ended` does about a link nobody outside the room can name.
172
+ * Omitted or false, the event is byte-identical to 0.8.0's. */
173
+ destruct?: boolean;
168
174
  /** A conference room's end, in unix seconds: the tombstone carries the
169
175
  * same NIP-40 expiration as the invitation it retires, and lapses with it. */
170
176
  endsAt?: number;
@@ -176,10 +182,12 @@ export declare function encodeInvitationRetirement(opts: EncodeInvitationRetirem
176
182
  /** A valid tombstone never expires: invitation ids are random and unique,
177
183
  * and a retired bearer must not become usable again after a timeout. */
178
184
  export declare function decodeInvitationRetirement(event: Event, invitation: RoomInvitation): boolean;
179
- /** A valid retirement, and whether it says the room was ended. Undefined
180
- * for anything that is not a valid retirement of this invitation. */
185
+ /** A valid retirement, whether it says the room was ended, and whether the
186
+ * ended room self-destructs (`destruct`, believed only beside `ended`).
187
+ * Undefined for anything that is not a valid retirement of this invitation. */
181
188
  export declare function decodeInvitationRetirementNotice(event: Event, invitation: RoomInvitation): {
182
189
  ended: boolean;
190
+ destruct?: true;
183
191
  } | undefined;
184
192
  /** The error a joiner rejects with on a retirement. */
185
193
  export declare function retirementError(notice: {
@@ -279,11 +279,13 @@ export function encodeInvitationRetirement(opts) {
279
279
  if (!hexEquals(getPublicKey(opts.inviterSk), opts.invitation.inviter)) {
280
280
  throw new Error('only the root inviter can retire an invitation');
281
281
  }
282
+ if (opts.destruct && !opts.ended)
283
+ throw new Error('only an ended room can self-destruct');
282
284
  return finalizeEvent({
283
285
  kind: KINDS.INVITATION_RETIREMENT,
284
286
  created_at: opts.now,
285
287
  tags: withExpiration([['d', deriveInvitationId(opts.invitation)]], opts.endsAt),
286
- content: JSON.stringify(opts.ended ? { v: 1, ended: true } : { v: 1 }),
288
+ content: JSON.stringify(opts.ended ? (opts.destruct ? { v: 1, ended: true, destruct: true } : { v: 1, ended: true }) : { v: 1 }),
287
289
  }, opts.inviterSk);
288
290
  }
289
291
  /** A valid tombstone never expires: invitation ids are random and unique,
@@ -291,8 +293,9 @@ export function encodeInvitationRetirement(opts) {
291
293
  export function decodeInvitationRetirement(event, invitation) {
292
294
  return decodeInvitationRetirementNotice(event, invitation) !== undefined;
293
295
  }
294
- /** A valid retirement, and whether it says the room was ended. Undefined
295
- * for anything that is not a valid retirement of this invitation. */
296
+ /** A valid retirement, whether it says the room was ended, and whether the
297
+ * ended room self-destructs (`destruct`, believed only beside `ended`).
298
+ * Undefined for anything that is not a valid retirement of this invitation. */
296
299
  export function decodeInvitationRetirementNotice(event, invitation) {
297
300
  try {
298
301
  if (event.kind !== KINDS.INVITATION_RETIREMENT)
@@ -304,7 +307,9 @@ export function decodeInvitationRetirementNotice(event, invitation) {
304
307
  if (event.tags.find((tag) => tag[0] === 'd')?.[1] !== deriveInvitationId(invitation))
305
308
  return undefined;
306
309
  const body = JSON.parse(event.content);
307
- return body.v === 1 ? { ended: body.ended === true } : undefined;
310
+ if (body.v !== 1)
311
+ return undefined;
312
+ return body.ended === true && body.destruct === true ? { ended: true, destruct: true } : { ended: body.ended === true };
308
313
  }
309
314
  catch {
310
315
  return undefined;
@@ -59,6 +59,8 @@ export interface RekeyEvidence {
59
59
  epoch: number;
60
60
  removed: string[];
61
61
  closed: boolean;
62
+ /** The closed room self-destructs: see `RekeyNotice.destruct`. */
63
+ destruct?: true;
62
64
  /** A scheduled turn of the key: see `RekeyNotice.scheduled`. */
63
65
  scheduled?: true;
64
66
  /** The epoch commitment, when the authority wrote one. */
@@ -117,6 +117,8 @@ export function readRekeyEvidence(event, opts) {
117
117
  removed: [...new Set(body.removed.map(normaliseHex))].sort(),
118
118
  closed: body.closed === true,
119
119
  };
120
+ if (body.destruct === true && evidence.closed)
121
+ evidence.destruct = true;
120
122
  if (body.scheduled === true && evidence.removed.length === 0 && !evidence.closed)
121
123
  evidence.scheduled = true;
122
124
  if (typeof body.commit === 'string' && HEX64.test(body.commit))
@@ -13,6 +13,9 @@ export interface PersistentRoomAdmission {
13
13
  /** The room's own relays, as its inviter signed them: every member's pool
14
14
  * includes them. Absent from an invitation written before 0.4.0. */
15
15
  relays?: string[];
16
+ /** The room self-destructs: when it ends, every device deletes what it
17
+ * wrote and forgets the room. Absent for a room that simply ends. */
18
+ destruct?: true;
16
19
  }
17
20
  /** Publish once before sharing. The link plus this envelope is a durable
18
21
  * bearer capability: anybody holding both can learn epoch 0. Retirement
@@ -30,6 +33,10 @@ export declare function encodePersistentInvitation(opts: {
30
33
  * form (see `isInvitationRelays`), else it throws. Omitted, the body is
31
34
  * byte-identical to 0.3.0's. */
32
35
  relays?: readonly string[];
36
+ /** The room self-destructs when it ends, by its time or by its authority
37
+ * closing it. Written inside the encrypted body only, never as a tag.
38
+ * Omitted or false, the event is byte-identical to 0.8.0's. */
39
+ destruct?: boolean;
33
40
  }): Event;
34
41
  export declare function decodePersistentInvitation(event: Event, invitation: RoomInvitation): PersistentRoomAdmission | null;
35
42
  /** Fetch the signed envelope and retirement in ONE stored-event query.
@@ -30,7 +30,7 @@ export function encodePersistentInvitation(opts) {
30
30
  created_at: opts.now,
31
31
  tags: ends === undefined ? [['d', deriveInvitationId(opts.invitation)]] : [['d', deriveInvitationId(opts.invitation)], ['expiration', String(ends)]],
32
32
  content: nip44.v2.encrypt(JSON.stringify({
33
- v: 3, room, secret: base64urlnopad.encode(opts.roomSecret), ...(ends === undefined ? {} : { ends }), ...(relays === undefined ? {} : { relays }),
33
+ v: 3, room, secret: base64urlnopad.encode(opts.roomSecret), ...(ends === undefined ? {} : { ends }), ...(opts.destruct ? { destruct: true } : {}), ...(relays === undefined ? {} : { relays }),
34
34
  }), welcomeKey(opts.invitation)),
35
35
  }, opts.inviterSk);
36
36
  }
@@ -62,9 +62,15 @@ export function decodePersistentInvitation(event, invitation) {
62
62
  // would write; a malformed one refuses the envelope rather than half of it.
63
63
  if (body.relays !== undefined && !isInvitationRelays(body.relays))
64
64
  return null;
65
+ // Self-destruct is `true` or absent; anything else is a malformed
66
+ // envelope, refused as a malformed end is.
67
+ if (body.destruct !== undefined && body.destruct !== true)
68
+ return null;
65
69
  const admission = ends === undefined ? { secret, persistent: true, epoch: 0 } : { secret, persistent: true, epoch: 0, endsAt: ends };
66
70
  if (body.relays !== undefined)
67
71
  admission.relays = [...body.relays];
72
+ if (body.destruct === true)
73
+ admission.destruct = true;
68
74
  return admission;
69
75
  }
70
76
  catch {
@@ -119,6 +125,10 @@ export function requestPersistentRoomAdmission(opts) {
119
125
  // end stands, so a stale copy can never keep a room open longer.
120
126
  if (admission?.endsAt !== undefined && (decoded.endsAt === undefined || decoded.endsAt > admission.endsAt))
121
127
  decoded.endsAt = admission.endsAt;
128
+ // Two signed copies that disagree on self-destruct: it sticks, for
129
+ // the same reason, so a stale copy can never keep a room's content.
130
+ if (admission?.destruct)
131
+ decoded.destruct = true;
122
132
  // Two signed copies that disagree on the room's relays: the newest
123
133
  // copy that names any stands. A copy naming none (an older writer)
124
134
  // says nothing about them, and on equal timestamps the first heard stays.
package/llms.txt CHANGED
@@ -68,6 +68,7 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
68
68
  - `requestPersistentRoomAdmission(opts)` - joins from stored admission alone, no live desk needed
69
69
  - Conference rooms: `encodePersistentInvitation({ ..., endsAt })` puts `ends` in the body and `['expiration', String(endsAt)]` on the 1463; `decodePersistentInvitation` returns `endsAt` and is null when an `expiration` tag disagrees with the body; `encodeInvitationRetirement({ ..., endsAt })` tags the 1461 the same way
70
70
  - Room relays: `encodePersistentInvitation({ ..., relays })` puts the room's own relays (1-8 distinct canonical `wss://host/` URLs, else it throws) in the body after `ends`; the decoder returns `relays` and is null on a malformed list; `requestPersistentRoomAdmission` keeps the newest signed copy's list (a copy without one says nothing); `isInvitationRelays`, `requireInvitationRelays`, `MAX_INVITATION_RELAYS` (8)
71
+ - Self-destructing rooms (see `docs/room-destruct.md`): `encodePersistentInvitation({ ..., destruct: true })` puts `"destruct": true` in the encrypted body after `ends` (valid without `ends`), never a tag; `decodePersistentInvitation` returns `destruct: true` and is null when the field is present and not `true`; `requestPersistentRoomAdmission` keeps `destruct` if any valid copy carries it (sticky, like the earliest `ends`). `encodeInvitationRetirement({ ..., ended: true, destruct: true })` writes `{"v":1,"ended":true,"destruct":true}` (plain JSON, like `ended`) and throws on `destruct` without `ended`; `decodeInvitationRetirementNotice` returns `{ ended, destruct?: true }`, believing `destruct` only beside `ended`. `encodeRekeyEvent({ ..., closed: true, destruct: true })` writes it in the encrypted body after `closed` and throws without `closed`; `RekeyNotice.destruct` / `RekeyEvidence.destruct` are set only on a closing body, and a flag on an open rekey is dropped, not refused. Without the flag every event is byte-identical to 0.8.0, and a 0.8.0 reader ignores it
71
72
  - `withExpiration(tags, expiresAt?)` - NIP-40 rule for a room that ends: add the tag, keep an earlier one, lower a later one; `isRoomEnds`, `requireRoomEnds(endsAt, now)`, `MAX_ROOM_ENDS_SECONDS` (30 days)
72
73
  - Epoch encoders (`encodeRekeyEvent`, `encodeEpochRequest`, `encodeEpochGrant`) and `hostRoomEpoch` / `requestRoomEpoch` take `expiresAt?` for a conference room
73
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/fold-kit",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",
@@ -23,6 +23,7 @@
23
23
  "vectors/member-epoch-vectors.json",
24
24
  "vectors/seal-vectors.json",
25
25
  "vectors/schedule-vectors.json",
26
+ "vectors/destruct-vectors.json",
26
27
  "LICENSE",
27
28
  "README.md",
28
29
  "llms.txt"
@@ -37,6 +38,7 @@
37
38
  "generate-member-epoch": "node scripts/generate-member-epoch.mjs",
38
39
  "generate-seal": "node scripts/generate-seal.mjs",
39
40
  "generate-schedule": "node scripts/generate-schedule.mjs",
41
+ "generate-destruct": "node scripts/generate-destruct.mjs",
40
42
  "bundle-check": "node scripts/bundle-check.mjs",
41
43
  "tarball-smoke": "node scripts/tarball-smoke.mjs",
42
44
  "vectors": "vitest run vectors/",
@@ -0,0 +1,675 @@
1
+ {
2
+ "protocolVersion": "kithmoot/v1",
3
+ "generatedBy": "scripts/generate-destruct.mjs (forgesworn/fold-kit)",
4
+ "nostrToolsVersion": "2.25.0",
5
+ "groups": {
6
+ "destruct": [
7
+ {
8
+ "name": "destruct-invitation",
9
+ "kind": "positive",
10
+ "note": "A kind-1463 group invitation for a conference room that self-destructs: `\"destruct\": true` inside the encrypted body, after `ends` and before `relays` (body order v, room, secret, ends, destruct, relays), and never as a tag, so a relay cannot tell it from any other invitation. A reader accepts the flag only as `true` (see `destruct-invitation-malformed`) and reports it as `destruct: true` on the admission. `randomHex`: the body nonce, the aux-rand. `unflagged` is the same invitation with the same draws and no flag, which is every invitation before 0.9.0: its body is `bodyJson` without `\"destruct\":true,`. `oldReader` is what a 0.8.0 reader makes of the flagged event: the same admission without `destruct`, so an old client joins and ends the room the old way.",
11
+ "input": {
12
+ "invitation": {
13
+ "bearerHex": "63da81a93ddf0a90ae34ed2b24bb91e664383e117bcacec5cc05e0da5d3d2612",
14
+ "inviter": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
15
+ "persistent": true
16
+ },
17
+ "inviterSkHex": "a090036fcabc0a442fc1eaa5afc5c7ddda6f95c6bf8fe1f7b3b69589e621d643",
18
+ "roomSecretHex": "76da765650f119b64a4c908c490992d125626bd272c90b8b3362010892356e28",
19
+ "roomId": "629cb9accf2af03a0da629f4cf0e2a8a88d2e127208116f0e849c107e21c51f6",
20
+ "createdAt": 1800000000,
21
+ "endsAt": 1800086400,
22
+ "relays": [
23
+ "wss://relay.example.com/"
24
+ ],
25
+ "event": {
26
+ "id": "526ed7535a2ead66313654f6b97a86af4d827af11b005ce2d248c675097d5c78",
27
+ "pubkey": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
28
+ "created_at": 1800000000,
29
+ "kind": 1463,
30
+ "tags": [
31
+ [
32
+ "d",
33
+ "3bbaca427676d0f5a796278a86bf2166d8f66e7abd09d40caa6581f1e7454914"
34
+ ],
35
+ [
36
+ "expiration",
37
+ "1800086400"
38
+ ]
39
+ ],
40
+ "content": "AhKIoHqYG4uOcnjHZ/7EHKpG1oknjFfGX/gvvT2u9PAIJYXCDlIPSvR5TutkG+Nx5hGvco9Z/C40vaXhVi4PNy7fETWGqCb+hFLpnZokAB2a2111Zc42hFUOrLnmahl6LXJdVtkgesGXnYe9LrskUR+PekcO+GazVPxDc7TOZRaCPd6tWLB37U+1o7w+jhyBPHZvurz59i7EqCK4cYgQox03i7JlXxOvT9I6ebQJJM5f1Z6zYmvuPIWMklHJEnJAGFFQ9JkhX3yQlTlYjhRn3e+B1ct3mAxFL5qoWBWXZ4TtTo4bINzqVBHWpnLmvZjhXBhGTVDJc6H8+azqOVhEqktzvIcn+TAGzVX5rywXlR3ZgNVLORtp/k70KakDt1dkKIp5",
41
+ "sig": "3db32e6460f29f804c3502a4156bec06d00ee4fa3f6e29c57193149d50d3c71075cfa930a981f89dd9919791d073c795a0f039a0061f0c1e9b18e19ea44f2afd"
42
+ },
43
+ "randomHex": [
44
+ "1288a07a981b8b8e7278c767fec41caa46d689278c57c65ff82fbd3daef4f008",
45
+ "e70b320ea3f8ea2efc3b6120d3a07908ee32f1d438332c02513ef5dde2d30d7e"
46
+ ],
47
+ "unflagged": {
48
+ "event": {
49
+ "id": "5b31789aca6f969e7472688636bd8ad236eb79a572f2b80793bfc1ac75288870",
50
+ "pubkey": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
51
+ "created_at": 1800000000,
52
+ "kind": 1463,
53
+ "tags": [
54
+ [
55
+ "d",
56
+ "3bbaca427676d0f5a796278a86bf2166d8f66e7abd09d40caa6581f1e7454914"
57
+ ],
58
+ [
59
+ "expiration",
60
+ "1800086400"
61
+ ]
62
+ ],
63
+ "content": "AhKIoHqYG4uOcnjHZ/7EHKpG1oknjFfGX/gvvT2u9PAIJZXCDlIPSvR5TutkG+Nx5hGvco9Z/C40vaXhVi4PNy7fETWGqCb+hFLpnZokAB2a2111Zc42hFUOrLnmahl6LXJdVtkgesGXnYe9LrskUR+PekcO+GazVPxDc7TOZRaCPd6tWLB37U+1o7w+jhyBPHZvurz59i7EqCK4cYgQox03i7JlXxOvT9I6ebQJJM5f1Z6zYmvuPIWMklHJEmRAB0Rb8thvJmSTlD8HjRln3e+B1ZYw2jYKKIW+TFnXeM6jcopWJM9Fcgn6nvuxBCDzIw8Cd4g8OKdKXeX9664RPqCRCA==",
64
+ "sig": "36a5dc73f330985626d91edaabd1a2fa6f3a27b885834fa6d8f4895f04a6dfe772feff95b79be0c20b15e878869844acf32189968191cbf209767622367824e0"
65
+ },
66
+ "randomHex": [
67
+ "1288a07a981b8b8e7278c767fec41caa46d689278c57c65ff82fbd3daef4f008",
68
+ "e70b320ea3f8ea2efc3b6120d3a07908ee32f1d438332c02513ef5dde2d30d7e"
69
+ ]
70
+ }
71
+ },
72
+ "output": {
73
+ "bodyJson": "{\"v\":3,\"room\":\"629cb9accf2af03a0da629f4cf0e2a8a88d2e127208116f0e849c107e21c51f6\",\"secret\":\"dtp2VlDxGbZKTJCMSQmS0SVia9JyyQuLM2IBCJI1big\",\"ends\":1800086400,\"destruct\":true,\"relays\":[\"wss://relay.example.com/\"]}",
74
+ "unflaggedBodyJson": "{\"v\":3,\"room\":\"629cb9accf2af03a0da629f4cf0e2a8a88d2e127208116f0e849c107e21c51f6\",\"secret\":\"dtp2VlDxGbZKTJCMSQmS0SVia9JyyQuLM2IBCJI1big\",\"ends\":1800086400,\"relays\":[\"wss://relay.example.com/\"]}",
75
+ "admission": {
76
+ "secretHex": "76da765650f119b64a4c908c490992d125626bd272c90b8b3362010892356e28",
77
+ "persistent": true,
78
+ "epoch": 0,
79
+ "endsAt": 1800086400,
80
+ "relays": [
81
+ "wss://relay.example.com/"
82
+ ],
83
+ "destruct": true
84
+ },
85
+ "unflaggedAdmission": {
86
+ "secretHex": "76da765650f119b64a4c908c490992d125626bd272c90b8b3362010892356e28",
87
+ "persistent": true,
88
+ "epoch": 0,
89
+ "endsAt": 1800086400,
90
+ "relays": [
91
+ "wss://relay.example.com/"
92
+ ]
93
+ },
94
+ "oldReader": {
95
+ "secretHex": "76da765650f119b64a4c908c490992d125626bd272c90b8b3362010892356e28",
96
+ "persistent": true,
97
+ "epoch": 0,
98
+ "endsAt": 1800086400,
99
+ "relays": [
100
+ "wss://relay.example.com/"
101
+ ]
102
+ }
103
+ }
104
+ },
105
+ {
106
+ "name": "destruct-invitation-no-end",
107
+ "kind": "positive",
108
+ "note": "A group invitation with `destruct` and no `ends`: valid. The room has no fixed end and self-destructs when its authority closes it. No expiration tag, as for any group with no end. `randomHex`: the body nonce, the aux-rand.",
109
+ "input": {
110
+ "invitation": {
111
+ "bearerHex": "63da81a93ddf0a90ae34ed2b24bb91e664383e117bcacec5cc05e0da5d3d2612",
112
+ "inviter": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
113
+ "persistent": true
114
+ },
115
+ "inviterSkHex": "a090036fcabc0a442fc1eaa5afc5c7ddda6f95c6bf8fe1f7b3b69589e621d643",
116
+ "roomSecretHex": "76da765650f119b64a4c908c490992d125626bd272c90b8b3362010892356e28",
117
+ "roomId": "629cb9accf2af03a0da629f4cf0e2a8a88d2e127208116f0e849c107e21c51f6",
118
+ "createdAt": 1800000000,
119
+ "event": {
120
+ "id": "824d7c025cda000955e697611ac8715cd04260bb71ac2b189325d421f5cc977b",
121
+ "pubkey": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
122
+ "created_at": 1800000000,
123
+ "kind": 1463,
124
+ "tags": [
125
+ [
126
+ "d",
127
+ "3bbaca427676d0f5a796278a86bf2166d8f66e7abd09d40caa6581f1e7454914"
128
+ ]
129
+ ],
130
+ "content": "Ar2/gSpXQYq/bOeYByDqnIjpXz+MkD5ZeS3d/zm3xXgfbKpwDAY4YHFki6YgOB91OIoom0qFYQ/7L3hykgYI+5sjoFHReBK5T4tdM2RZsUsuNGztNC6PHuCuxWoR3t1+li3Xk0GW1s5RjbGjWAHFMl/3/HGj9zTaLwgfwy1T61bLlcLYxhtRkF6IHHsNegpjRP6fiq3cGY661hG3+PkcOTjIDtSZdvzPG7KbvZmuFXBJ611pApyK8VfZICk+mC2ls9Gj609S2guZoBntEnQdAmsSK9st+UXeVtMR18850DlKLgY=",
131
+ "sig": "c576a3a1e3149b723136ced230d5934e2f1fb18abaaba95a88069f9db0f62edc92472881a772e133a580b264d3ad64e3242afaca0114ee7b22a9117825c341b3"
132
+ },
133
+ "randomHex": [
134
+ "bdbf812a57418abf6ce7980720ea9c88e95f3f8c903e59792dddff39b7c5781f",
135
+ "556b37bc374f271443b645f473fe0fc07ef5384c628e3a14cc936136f8dccb3d"
136
+ ]
137
+ },
138
+ "output": {
139
+ "bodyJson": "{\"v\":3,\"room\":\"629cb9accf2af03a0da629f4cf0e2a8a88d2e127208116f0e849c107e21c51f6\",\"secret\":\"dtp2VlDxGbZKTJCMSQmS0SVia9JyyQuLM2IBCJI1big\",\"destruct\":true}",
140
+ "admission": {
141
+ "secretHex": "76da765650f119b64a4c908c490992d125626bd272c90b8b3362010892356e28",
142
+ "persistent": true,
143
+ "epoch": 0,
144
+ "destruct": true
145
+ },
146
+ "oldReader": {
147
+ "secretHex": "76da765650f119b64a4c908c490992d125626bd272c90b8b3362010892356e28",
148
+ "persistent": true,
149
+ "epoch": 0
150
+ }
151
+ }
152
+ },
153
+ {
154
+ "name": "destruct-invitation-malformed",
155
+ "kind": "negative",
156
+ "note": "Group invitations signed by the inviter whose body carries `destruct` as something other than `true`: `false`, the string `\"true\"`, `1`, `null`. Built by hand (`bodyJson`, then `randomHex`: the body nonce, the aux-rand). A reader refuses each envelope outright (null), as it refuses a malformed `ends`. A 0.8.0 reader, which never looks at the field, still admits each one (`oldReader`).",
157
+ "input": {
158
+ "invitation": {
159
+ "bearerHex": "63da81a93ddf0a90ae34ed2b24bb91e664383e117bcacec5cc05e0da5d3d2612",
160
+ "inviter": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
161
+ "persistent": true
162
+ },
163
+ "inviterSkHex": "a090036fcabc0a442fc1eaa5afc5c7ddda6f95c6bf8fe1f7b3b69589e621d643",
164
+ "roomSecretHex": "76da765650f119b64a4c908c490992d125626bd272c90b8b3362010892356e28",
165
+ "roomId": "629cb9accf2af03a0da629f4cf0e2a8a88d2e127208116f0e849c107e21c51f6",
166
+ "cases": {
167
+ "false": {
168
+ "event": {
169
+ "id": "99bb8b1f573f4c0bf83a43e7f9d22fb3d5798dc1c6f34a897afd0e5c33341482",
170
+ "pubkey": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
171
+ "created_at": 1800000000,
172
+ "kind": 1463,
173
+ "tags": [
174
+ [
175
+ "d",
176
+ "3bbaca427676d0f5a796278a86bf2166d8f66e7abd09d40caa6581f1e7454914"
177
+ ]
178
+ ],
179
+ "content": "Auri0a+JHb2EUxVXoLZ7pCtFilbL6S8+xF0q5zBJKVpeaRq0c86+ZnMcHmfp+5tm/DX+XdKXmX9yOMLz8mkKt3n2ZJg+GgnC/t1L0LlokgaVH4CPGT8+C7yTiJFIFdNF6c76Ta+t2cZuatA+PQTHruBDafFG8BvTYpWeEHlyiYrdbRplFg4jso37JisPZB2g48GLKwQQHR1qjyfKd+nKPqFbG3mDVe82ISAntabZz855S3fqzMW+roGKb8tksDe4SS2Zqvg0pozhgxCaTw1gC92loFh6DSDltz/vyZR9pYcMwQ0=",
180
+ "sig": "614e6bd4dd94ce5569c157bd00f3afdac875656eb0beac417aa7544c82673eafd4c7941a1bace18268a4e374e2ed3e2231df32d15e5d63633347bd3e62a2bc53"
181
+ },
182
+ "randomHex": [
183
+ "eae2d1af891dbd84531557a0b67ba42b458a56cbe92f3ec45d2ae73049295a5e",
184
+ "ced40f26ae7e9c2907fbdce2991a8daacefb7739c0780b4e186f4d07f2775ea8"
185
+ ],
186
+ "bodyJson": "{\"v\":3,\"room\":\"629cb9accf2af03a0da629f4cf0e2a8a88d2e127208116f0e849c107e21c51f6\",\"secret\":\"dtp2VlDxGbZKTJCMSQmS0SVia9JyyQuLM2IBCJI1big\",\"destruct\":false}"
187
+ },
188
+ "string": {
189
+ "event": {
190
+ "id": "e00d2125c88e132c21ce1519d17cdd541467240210dc4c5fc9520b2c495a8f96",
191
+ "pubkey": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
192
+ "created_at": 1800000000,
193
+ "kind": 1463,
194
+ "tags": [
195
+ [
196
+ "d",
197
+ "3bbaca427676d0f5a796278a86bf2166d8f66e7abd09d40caa6581f1e7454914"
198
+ ]
199
+ ],
200
+ "content": "AoSjS3R7pOmM6LqSsTwJC38zSylOxll/zIGx620jEFSMCxtmiegakh9oyofzUB5yncm6wHTAC9xjNh9PrfN2U3L3XHo0Wi5RcqKT6kPvYLKFzbKlXefM1V3uxiaFLsAW3sErkOIWKbC7+4ZVW8LDQv58PKeXPY9E68qxq78Wbbd9i1RW1dSdmZzBhGDIQB5xRP+sZXwtXEhnaL84lE4vXwGJI87eWMC0iC8vAiKiiuQfldgDqzyhHYZjE3IHAYX2nAqmD0PdEwOBj1fsjrb7g0hs+LPnXqNiP22SsyEIlqk1OH8=",
201
+ "sig": "fc52b06b5d089bb6837f6cb4fd501927abcd5cf8973f0a48946ee0912e74ad63c9a8588a47f328b6e1030a1204333a37ce6d8d6d72b114893a85392b2426253f"
202
+ },
203
+ "randomHex": [
204
+ "84a34b747ba4e98ce8ba92b13c090b7f334b294ec6597fcc81b1eb6d2310548c",
205
+ "b4114d68af4779911af998dd2d43e56db75fd2e686e8bf63e43cdabc2755ed1d"
206
+ ],
207
+ "bodyJson": "{\"v\":3,\"room\":\"629cb9accf2af03a0da629f4cf0e2a8a88d2e127208116f0e849c107e21c51f6\",\"secret\":\"dtp2VlDxGbZKTJCMSQmS0SVia9JyyQuLM2IBCJI1big\",\"destruct\":\"true\"}"
208
+ },
209
+ "number": {
210
+ "event": {
211
+ "id": "f97f3de9b8b1227b9fe5f911dfaab269f5a6927df6f612688b9fd934ca87cc09",
212
+ "pubkey": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
213
+ "created_at": 1800000000,
214
+ "kind": 1463,
215
+ "tags": [
216
+ [
217
+ "d",
218
+ "3bbaca427676d0f5a796278a86bf2166d8f66e7abd09d40caa6581f1e7454914"
219
+ ]
220
+ ],
221
+ "content": "AmHcx8yLgRa/Oa+sIw47P4MwMV2AuEORn1XSQQWV+oz4Fgbj3HSNanTE2MC8iV4j4w3UmHrLA62SV7Plti2xjDwhYJTW+XJMLu2on/PS/lt/Kl2XvAHXnHrJ/gNSma+JpJyL/YIW3aNbaWp/yHQossFjr+t7A7Ej3oz6tHdrdaaQpH1vfyg44hC+T0dlKr+0xZg23Is4Uf0TsM287oNru2JhV6+CqET4MGpUaFvxKulSRQpvX+tZPHwSQHplf+lFvHvZUpErDzqhS8tmH34VEIhCPnT25mpu8NKBr3HN0xyrl+8=",
222
+ "sig": "cd714956b3e96a6deccc1654ff823de404cfb85d548c0f9b478c35e0507dff960b146a56f41a34ccf59192d255ca488a216edc3c4b261508fe86c2e1fc5f4133"
223
+ },
224
+ "randomHex": [
225
+ "61dcc7cc8b8116bf39afac230e3b3f8330315d80b843919f55d2410595fa8cf8",
226
+ "cc4d068aff3fa914a212bf68ed39d9b402c2f7bc8c540acb490b3d017f476ac9"
227
+ ],
228
+ "bodyJson": "{\"v\":3,\"room\":\"629cb9accf2af03a0da629f4cf0e2a8a88d2e127208116f0e849c107e21c51f6\",\"secret\":\"dtp2VlDxGbZKTJCMSQmS0SVia9JyyQuLM2IBCJI1big\",\"destruct\":1}"
229
+ },
230
+ "null": {
231
+ "event": {
232
+ "id": "a31ba9cf7106813bd2d01de861e7f612be29adcbc84879fed99bb3b0b98d536e",
233
+ "pubkey": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
234
+ "created_at": 1800000000,
235
+ "kind": 1463,
236
+ "tags": [
237
+ [
238
+ "d",
239
+ "3bbaca427676d0f5a796278a86bf2166d8f66e7abd09d40caa6581f1e7454914"
240
+ ]
241
+ ],
242
+ "content": "AsvyriQzQy6dRH/yC3e8fNvxU5i/lR8OixB3yVytXzq7+3SYS4KZzKfQYNzSndix9QQYHUgO2S5euVOTo19DMCbpC1IrnIL4UfFWgSx4XqzFfQGQ4UtXO9S+gRJjzkyJHrvNETeIrotXcAABG1Mo/gr6nHhhAMw1+YiPH9RJMFwb8djuBjfVriwEQwq6IFn8IctDDy9DA8Ami8zNVxcX9Fn1Iw0JHZsLpMN7xC4DMEW35IxUHg0kRB2sLPTfv8+DChAmWHj4vk737sIQqJ1cSuuBm7G1w6Olo0ybM7eJAt9YWhI=",
243
+ "sig": "f828a69d9e58c976180f848142d154faf4c0338d09cf74fb92597ab89a63a49cd12f38f145658dc3ce84e80a1df83c083263da5d548debfd6bdf744a763a4001"
244
+ },
245
+ "randomHex": [
246
+ "cbf2ae2433432e9d447ff20b77bc7cdbf15398bf951f0e8b1077c95cad5f3abb",
247
+ "006a1daf9bd649cd57d2e5cbf4c51a8549fb5bfbd0035fdbac1d89be0a76b3e2"
248
+ ],
249
+ "bodyJson": "{\"v\":3,\"room\":\"629cb9accf2af03a0da629f4cf0e2a8a88d2e127208116f0e849c107e21c51f6\",\"secret\":\"dtp2VlDxGbZKTJCMSQmS0SVia9JyyQuLM2IBCJI1big\",\"destruct\":null}"
250
+ }
251
+ }
252
+ },
253
+ "output": {
254
+ "admission": {
255
+ "false": null,
256
+ "string": null,
257
+ "number": null,
258
+ "null": null
259
+ },
260
+ "oldReader": {
261
+ "false": {
262
+ "secretHex": "76da765650f119b64a4c908c490992d125626bd272c90b8b3362010892356e28",
263
+ "persistent": true,
264
+ "epoch": 0
265
+ },
266
+ "string": {
267
+ "secretHex": "76da765650f119b64a4c908c490992d125626bd272c90b8b3362010892356e28",
268
+ "persistent": true,
269
+ "epoch": 0
270
+ },
271
+ "number": {
272
+ "secretHex": "76da765650f119b64a4c908c490992d125626bd272c90b8b3362010892356e28",
273
+ "persistent": true,
274
+ "epoch": 0
275
+ },
276
+ "null": {
277
+ "secretHex": "76da765650f119b64a4c908c490992d125626bd272c90b8b3362010892356e28",
278
+ "persistent": true,
279
+ "epoch": 0
280
+ }
281
+ }
282
+ }
283
+ },
284
+ {
285
+ "name": "destruct-invitation-copies",
286
+ "kind": "positive",
287
+ "note": "Two signed copies of one group invitation that disagree: `older` (created_at NOW) self-destructs, `newer` (NOW + 60) names the room relays and has no flag. `requestPersistentRoomAdmission` hears both and admits with `destruct: true` in either order (`olderFirst`, `newerFirst`; `alone` is each copy read by itself): self-destruct is sticky, as the earlier `ends` is, so a stale or careless copy can never keep a room's content alive. The relays still come from the newest copy that names any. `randomHex` on each: the body nonce, the aux-rand.",
288
+ "input": {
289
+ "invitation": {
290
+ "bearerHex": "63da81a93ddf0a90ae34ed2b24bb91e664383e117bcacec5cc05e0da5d3d2612",
291
+ "inviter": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
292
+ "persistent": true
293
+ },
294
+ "inviterSkHex": "a090036fcabc0a442fc1eaa5afc5c7ddda6f95c6bf8fe1f7b3b69589e621d643",
295
+ "roomSecretHex": "76da765650f119b64a4c908c490992d125626bd272c90b8b3362010892356e28",
296
+ "roomId": "629cb9accf2af03a0da629f4cf0e2a8a88d2e127208116f0e849c107e21c51f6",
297
+ "older": {
298
+ "event": {
299
+ "id": "bf927b24f6956b3b51c4db990fb34e194e33efe3e1c9c43aa51653e41c52f9db",
300
+ "pubkey": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
301
+ "created_at": 1800000000,
302
+ "kind": 1463,
303
+ "tags": [
304
+ [
305
+ "d",
306
+ "3bbaca427676d0f5a796278a86bf2166d8f66e7abd09d40caa6581f1e7454914"
307
+ ]
308
+ ],
309
+ "content": "AjHpz19NgPNarY9R7eWHpy4k3zsjLXvATwVknx+DPW05EKLaaWW/XgjOw8aPMVwqt1ZaolyOMT6s2gUerWCaEIp38NjKsirkNmpKCw3cd3Ouzzu2nn5+QgGJAMH3OaZSRu4Zu9b/VqO/HhMrWQ92XILwuutTn+BrrwoJmG4V571RpS866L0XM9JE2Mkup/zuzZvmhoqQC30fwUMqlNvWilAuJF9MVW0nzw0+nVBXQkf3mQZmDAa053Jfpi4UJ3ENu1TMZ73r/LDlgFlavIhC7eS7DRwb3/Kl75hVhdfFv1O7R6A=",
310
+ "sig": "ab8d460d5d571859ad59d7ec17675f2a9c63abed76c09837626c7ef548f455959ca63168af56ab9122ffa54e8a84ecdb176ca252822f48d7b7faa511c9cb61e3"
311
+ },
312
+ "randomHex": [
313
+ "31e9cf5f4d80f35aad8f51ede587a72e24df3b232d7bc04f05649f1f833d6d39",
314
+ "aed5df214155aefbdd54cdf7eb415014ba000d9a4f1f787e09f17ec5d03cd3f4"
315
+ ]
316
+ },
317
+ "newer": {
318
+ "event": {
319
+ "id": "50457bca51dd68b0d15cb16c5d3ebe7c5e748e3ea460edf204900f73de9dd074",
320
+ "pubkey": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
321
+ "created_at": 1800000060,
322
+ "kind": 1463,
323
+ "tags": [
324
+ [
325
+ "d",
326
+ "3bbaca427676d0f5a796278a86bf2166d8f66e7abd09d40caa6581f1e7454914"
327
+ ]
328
+ ],
329
+ "content": "AiSsGzVre7NuU2R1ilNei7Np9z/Rg1ijlatOwpbs8s31YE9g/IDAm9iTlkZlezUCRz4et94GEr1h38NGMjTWbQzLYn10jhxy0+o4sUXP1DNLVIBkIMShLReqDxQC7sC/pDHxplHslGnB8CBxfEafsMQLv8A8UxWp7NZio7sGCNe6a95ZazFqokLImMrmJpjkqCbIFqzGzoH6HElvEVKMWTSS4YpGLYk2tjH7h8KlJYoxr3jPV5dAOKLCwAGMJ2LWGs9DLfIjWmdPnSPQPdQK214ZsNuPst1ObckGmb88UozIpW/ppOr5uS7q7eFSyYq/tJVJAlmQUVuih6pi4L7ZBJC0AQ==",
330
+ "sig": "450526ef0933591e9e3af2cddaf7936dc43247f8ab5427bd65fbd55c2b62a0710aabc25e70515f4216c41df14a636c0cf6a3d3006f8750c1ef43c680d2f6aafb"
331
+ },
332
+ "randomHex": [
333
+ "24ac1b356b7bb36e5364758a535e8bb369f73fd18358a395ab4ec296ecf2cdf5",
334
+ "f7c71d2ef1e5e0ca0962d40476ace63fb43012c671ba6e6a7c9130060beda77b"
335
+ ]
336
+ }
337
+ },
338
+ "output": {
339
+ "olderFirst": {
340
+ "secretHex": "76da765650f119b64a4c908c490992d125626bd272c90b8b3362010892356e28",
341
+ "persistent": true,
342
+ "epoch": 0,
343
+ "relays": [
344
+ "wss://relay.example.com/"
345
+ ],
346
+ "destruct": true
347
+ },
348
+ "newerFirst": {
349
+ "secretHex": "76da765650f119b64a4c908c490992d125626bd272c90b8b3362010892356e28",
350
+ "persistent": true,
351
+ "epoch": 0,
352
+ "relays": [
353
+ "wss://relay.example.com/"
354
+ ],
355
+ "destruct": true
356
+ },
357
+ "alone": {
358
+ "older": {
359
+ "secretHex": "76da765650f119b64a4c908c490992d125626bd272c90b8b3362010892356e28",
360
+ "persistent": true,
361
+ "epoch": 0,
362
+ "destruct": true
363
+ },
364
+ "newer": {
365
+ "secretHex": "76da765650f119b64a4c908c490992d125626bd272c90b8b3362010892356e28",
366
+ "persistent": true,
367
+ "epoch": 0,
368
+ "relays": [
369
+ "wss://relay.example.com/"
370
+ ]
371
+ }
372
+ }
373
+ }
374
+ },
375
+ {
376
+ "name": "destruct-retirement",
377
+ "kind": "positive",
378
+ "note": "A kind-1461 retirement of the invitation above, saying the room was ended and self-destructs: content `{\"v\":1,\"ended\":true,\"destruct\":true}`. This content is plain JSON, not encrypted, as `ended` always was; the `d` tag is the invitation id, which nobody without the link can tie to a room. A reader reports `{ ended: true, destruct: true }`. `randomHex`: the aux-rand. `unflagged` is the same retirement with the same draw and no flag (0.8.0's `ended` retirement). `oldReader`: a 0.8.0 reader sees an ordinary ended room.",
379
+ "input": {
380
+ "invitation": {
381
+ "bearerHex": "63da81a93ddf0a90ae34ed2b24bb91e664383e117bcacec5cc05e0da5d3d2612",
382
+ "inviter": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
383
+ "persistent": true
384
+ },
385
+ "inviterSkHex": "a090036fcabc0a442fc1eaa5afc5c7ddda6f95c6bf8fe1f7b3b69589e621d643",
386
+ "createdAt": 1800003600,
387
+ "event": {
388
+ "id": "88503f949adc9ef36f283cb5956f6d6e19267c9c3873508dafb4f1e07178b1ff",
389
+ "pubkey": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
390
+ "created_at": 1800003600,
391
+ "kind": 1461,
392
+ "tags": [
393
+ [
394
+ "d",
395
+ "3bbaca427676d0f5a796278a86bf2166d8f66e7abd09d40caa6581f1e7454914"
396
+ ]
397
+ ],
398
+ "content": "{\"v\":1,\"ended\":true,\"destruct\":true}",
399
+ "sig": "e71b9f81f82c7c683ffc0d52d97396a6d5780e78163b84bfd5ede41b3ec126e11811a555b30382fe544add45eaf149a83f58f8011dacdb0fae3ee0e0070d5e91"
400
+ },
401
+ "randomHex": [
402
+ "a949d71b4873256ad433d5d1de0e9551b2f44fa63becdfef2554843e8dcc9b2e"
403
+ ],
404
+ "unflagged": {
405
+ "event": {
406
+ "id": "f7cba6d0721398ba9b6f5d79e16600da534c990f223db68291e68dc1e30e3c98",
407
+ "pubkey": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
408
+ "created_at": 1800003600,
409
+ "kind": 1461,
410
+ "tags": [
411
+ [
412
+ "d",
413
+ "3bbaca427676d0f5a796278a86bf2166d8f66e7abd09d40caa6581f1e7454914"
414
+ ]
415
+ ],
416
+ "content": "{\"v\":1,\"ended\":true}",
417
+ "sig": "e299e745d62ac489a69566d557584bccabbe932aaaba15ad2ce53f7251317c3e8393c334a15f76e2746a5e310f5070f78b109d10317f326dd11f04a6c23d7067"
418
+ },
419
+ "randomHex": [
420
+ "a949d71b4873256ad433d5d1de0e9551b2f44fa63becdfef2554843e8dcc9b2e"
421
+ ]
422
+ }
423
+ },
424
+ "output": {
425
+ "notice": {
426
+ "ended": true,
427
+ "destruct": true
428
+ },
429
+ "unflaggedNotice": {
430
+ "ended": true
431
+ },
432
+ "oldReader": {
433
+ "ended": true
434
+ }
435
+ }
436
+ },
437
+ {
438
+ "name": "destruct-retirement-not-ended",
439
+ "kind": "negative",
440
+ "note": "A retirement signed by the inviter whose content is `{\"v\":1,\"destruct\":true}`: a flag without `ended`. The encoder refuses it (`encoder`), so it is built by hand (`randomHex`: the aux-rand). A reader still honours the retirement, since a retired link must stay retired, and does not believe the flag: `{ ended: false }`.",
441
+ "input": {
442
+ "invitation": {
443
+ "bearerHex": "63da81a93ddf0a90ae34ed2b24bb91e664383e117bcacec5cc05e0da5d3d2612",
444
+ "inviter": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
445
+ "persistent": true
446
+ },
447
+ "event": {
448
+ "id": "65c29f31e3520b2ee8ea981904a48edb8ee75a39b841710d68a9f124b1caa08b",
449
+ "pubkey": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
450
+ "created_at": 1800003600,
451
+ "kind": 1461,
452
+ "tags": [
453
+ [
454
+ "d",
455
+ "3bbaca427676d0f5a796278a86bf2166d8f66e7abd09d40caa6581f1e7454914"
456
+ ]
457
+ ],
458
+ "content": "{\"v\":1,\"destruct\":true}",
459
+ "sig": "519018291651ba182de5f1d34355539fdaed555f35863e97030a2613a4b536700481a83b6aeecf4acbbdb74e28b2a3ba73a8ab7996e5a15b506ac19792bde684"
460
+ },
461
+ "randomHex": [
462
+ "7e483c437263f98c287eb2c2a76ab6ad25f2c306cfeaa5e2d86f0c4a6b93931d"
463
+ ],
464
+ "bodyJson": "{\"v\":1,\"destruct\":true}"
465
+ },
466
+ "output": {
467
+ "encoder": "refused",
468
+ "notice": {
469
+ "ended": false
470
+ }
471
+ }
472
+ },
473
+ {
474
+ "name": "destruct-closure",
475
+ "kind": "positive",
476
+ "note": "A kind-1462 rekey from epoch 1 to 2 that closes the room and self-destructs it: `\"closed\": true, \"destruct\": true` inside the body encrypted to epoch 1 (body order v, epoch, removed, closed, destruct, keys), and never as a tag. A closure seals the new secret to nobody, so `keys` is empty and the only draws are `randomHex`: the body nonce, the aux-rand. `unflagged` is the same closure with the same draws and no flag (every closure before 0.9.0). `notice` is `decodeRekeyEvent`, `evidence` is `readRekeyEvidence`; both report `destruct`. `oldReader`: a 0.8.0 reader sees an ordinary close.",
477
+ "input": {
478
+ "roomId": "629cb9accf2af03a0da629f4cf0e2a8a88d2e127208116f0e849c107e21c51f6",
479
+ "authority": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
480
+ "authoritySkHex": "a090036fcabc0a442fc1eaa5afc5c7ddda6f95c6bf8fe1f7b3b69589e621d643",
481
+ "previous": {
482
+ "epoch": 1,
483
+ "secretHex": "0f01eb2bc987461c1d130d1fd592156e3098a2f970dfd67057f5eeaa4bf812e4"
484
+ },
485
+ "next": {
486
+ "epoch": 2,
487
+ "secretHex": "9fdaac66de22f73960879abf83e9065bc882ba84440800c96418b27e4dfaec05"
488
+ },
489
+ "recipients": [
490
+ "b7229740b568be30f7307667afc9d23a292b9efa8dffbc6dbfc4a014048fb797"
491
+ ],
492
+ "deviceSkHex": "2ace7e7b383f931a0d45b2bc74df1262d356ec4a7137d88792e89b664828d698",
493
+ "createdAt": 1800003600,
494
+ "event": {
495
+ "id": "37458191b69ad786b091c3b695507a52a0b8596431f286d7a44daf91b7a7398a",
496
+ "pubkey": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
497
+ "created_at": 1800003600,
498
+ "kind": 1462,
499
+ "tags": [
500
+ [
501
+ "d",
502
+ "629cb9accf2af03a0da629f4cf0e2a8a88d2e127208116f0e849c107e21c51f6"
503
+ ],
504
+ [
505
+ "epoch",
506
+ "2"
507
+ ]
508
+ ],
509
+ "content": "AhOnbBRNMDzM9miIeGKbl2Dua0UpbT7Ifq5pljxTcm/R4wocpZDmpbWrmCHysCzurnZABNn7QFp3ZzwJzSRv7zyQlWFjWaNHEi0W4yYV3cjp3EE4Sfq+iPy8bKAmXjq2VDqlnVoiV1VMSsJN3pivf/gviJ3SJX18QZUR0XHMN0hZ0C41InAxa5whdgycHfKNHoRP7cVh6Gan3JBRU06GCAQcSQ==",
510
+ "sig": "781c78fc9fc9ec22b9b0eb37110a4794f27fef05bba9ae0c99323bed3c6f61c546dee16dc19fb37382c93e0dff3345c28f1cb2f4e9411c61811d820c1dbe6f1d"
511
+ },
512
+ "randomHex": [
513
+ "13a76c144d303cccf6688878629b9760ee6b45296d3ec87eae69963c53726fd1",
514
+ "3b0382104a935d062308889ecba5aca4d961b92670de5a7c0940e1415d0c7c30"
515
+ ],
516
+ "unflagged": {
517
+ "event": {
518
+ "id": "88c5d00afcdba39ed0f771642f18ff390168509116cedf1e6bf6f3993fc79290",
519
+ "pubkey": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
520
+ "created_at": 1800003600,
521
+ "kind": 1462,
522
+ "tags": [
523
+ [
524
+ "d",
525
+ "629cb9accf2af03a0da629f4cf0e2a8a88d2e127208116f0e849c107e21c51f6"
526
+ ],
527
+ [
528
+ "epoch",
529
+ "2"
530
+ ]
531
+ ],
532
+ "content": "AhOnbBRNMDzM9miIeGKbl2Dua0UpbT7Ifq5pljxTcm/R43ocpZDmpbWrmCHysCzurnZABNn7QFp3ZzwJzSRv7zyQlWFjWaNHEi0W4yYV3cjm3Es/GbWmgaOGGNJTOxaUP1/cxa5YK7MiGMSmucDORDd1IQZy1aTeyii0eGDaRCofQvA=",
533
+ "sig": "cd0c9a216f9d46ffca874bf5aed2c64d5216b2e36b87b0900c588fcd00d833bbe0698f2e7cc46ef96e9988277beaa1028f777cd6d59f8bfb2c4fb4d570ab69d2"
534
+ },
535
+ "randomHex": [
536
+ "13a76c144d303cccf6688878629b9760ee6b45296d3ec87eae69963c53726fd1",
537
+ "3b0382104a935d062308889ecba5aca4d961b92670de5a7c0940e1415d0c7c30"
538
+ ]
539
+ }
540
+ },
541
+ "output": {
542
+ "bodyJson": "{\"v\":1,\"epoch\":2,\"removed\":[],\"closed\":true,\"destruct\":true,\"keys\":{}}",
543
+ "unflaggedBodyJson": "{\"v\":1,\"epoch\":2,\"removed\":[],\"closed\":true,\"keys\":{}}",
544
+ "notice": {
545
+ "epoch": 2,
546
+ "removed": [],
547
+ "closed": true,
548
+ "destruct": true,
549
+ "at": 1800003600
550
+ },
551
+ "evidence": {
552
+ "epoch": 2,
553
+ "removed": [],
554
+ "closed": true,
555
+ "destruct": true
556
+ },
557
+ "unflaggedNotice": {
558
+ "epoch": 2,
559
+ "removed": [],
560
+ "closed": true,
561
+ "at": 1800003600
562
+ },
563
+ "oldReader": {
564
+ "epoch": 2,
565
+ "removed": [],
566
+ "closed": true,
567
+ "at": 1800003600
568
+ }
569
+ }
570
+ },
571
+ {
572
+ "name": "destruct-rekey-open",
573
+ "kind": "negative",
574
+ "note": "Two rekeys from epoch 1 to 2, signed by the authority, whose bodies carry `destruct` without `closed`: beside a removal (`withRemoval`) and beside the scheduled marker (`withScheduled`). The encoder refuses both (`encoder`), so they are built by hand (`bodyJson`, then `randomHex`: the body nonce, the aux-rand). A reader still reads each rekey, so the removal is announced and the device moves on, and does not believe the flag: it is absent from `notice` and `evidence`.",
575
+ "input": {
576
+ "roomId": "629cb9accf2af03a0da629f4cf0e2a8a88d2e127208116f0e849c107e21c51f6",
577
+ "authority": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
578
+ "previous": {
579
+ "epoch": 1,
580
+ "secretHex": "0f01eb2bc987461c1d130d1fd592156e3098a2f970dfd67057f5eeaa4bf812e4"
581
+ },
582
+ "deviceSkHex": "2ace7e7b383f931a0d45b2bc74df1262d356ec4a7137d88792e89b664828d698",
583
+ "withRemoval": {
584
+ "event": {
585
+ "id": "86e6fd76b5ffd82d455130760697f4b81022123750803cfa2d40a6751579ec88",
586
+ "pubkey": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
587
+ "created_at": 1800000000,
588
+ "kind": 1462,
589
+ "tags": [
590
+ [
591
+ "d",
592
+ "629cb9accf2af03a0da629f4cf0e2a8a88d2e127208116f0e849c107e21c51f6"
593
+ ],
594
+ [
595
+ "epoch",
596
+ "2"
597
+ ]
598
+ ],
599
+ "content": "Apee+KxMVIt4qxTDWlRzJE+t5N2mWq+KOdlTakLIH3L2Z6ibR4g0cAOAuPPHVtmavPV9m5xySfg3jBwV3bG6mdoSTKbzURzy0WvBRNjVKL8xovNTae2nMMxN5Pe6G27gnHpJ7Rd/zQtiTT0VlLLvPpuxLZAhuPhMGh73N8Lx3qeuCquspdQ7vab5Y3qAOEHTNIzN5gMYao9m7+zNjDjPp7HfeeWuA1wbe4XAz/xYEacvP2UypRVIL8UtOhJggFHzbIKB",
600
+ "sig": "8d4f94c1500929bacbb060760e5eadecabdb04ef15ae88de97af3ca3fc10803346d5de8baf2c3afd2087cc341d9ceed8c0bb2de40cf51eabaff950a35d81dd65"
601
+ },
602
+ "randomHex": [
603
+ "979ef8ac4c548b78ab14c35a5473244fade4dda65aaf8a39d9536a42c81f72f6",
604
+ "9749a08bd8414bac8d9cd070954da2e0f3e879b0221a0da9506576e56ab0e04c"
605
+ ],
606
+ "bodyJson": "{\"v\":1,\"epoch\":2,\"removed\":[\"e795ed3fc0ba9cd6635f68e50a6123c15fb0b947b4030ae40b2768b08e27b827\"],\"destruct\":true,\"keys\":{}}"
607
+ },
608
+ "withScheduled": {
609
+ "event": {
610
+ "id": "ec2c2987c0a28287e39b35de0ff7bf247640e061c46fd8a049ebb76932e1e584",
611
+ "pubkey": "66df4b7febed3972515d9f663fe00a8ef9b2c69fbcb6bee3f5587b5d0b96605e",
612
+ "created_at": 1800000000,
613
+ "kind": 1462,
614
+ "tags": [
615
+ [
616
+ "d",
617
+ "629cb9accf2af03a0da629f4cf0e2a8a88d2e127208116f0e849c107e21c51f6"
618
+ ],
619
+ [
620
+ "epoch",
621
+ "2"
622
+ ]
623
+ ],
624
+ "content": "AoH8yCso6ZNKjV9SGxSnVlzR7LBTCbHgXvGBp1BdAEwp67WbwBnUhdIYTE3OJMrwTRKpkbHSAbSL/HuLaXKNQ6QauW0MGXITkcOTX6ASQM2LMMtHHI7qP0xh8aAxUUzZWlB2M6k9A0Zdp1g0XQDo1Zq5ag4XEHoPmz++SnbM1APZs+ymM6U2bGEcgb4HZ9FVpGxZ/+LgM09t7uv+NQgLlwIwIA==",
625
+ "sig": "816c0323a3dbd9a5d7a40096b500bf62534b4a8909c14723ea27469a9c10be613b89d83e0176729c3cf45494b62d89e809c112e5385518d3ca0085e0cad44018"
626
+ },
627
+ "randomHex": [
628
+ "81fcc82b28e9934a8d5f521b14a7565cd1ecb05309b1e05ef181a7505d004c29",
629
+ "bb35e9d71648525a76693a2eb68580502684d0456c67c66d37eb0136343d9911"
630
+ ],
631
+ "bodyJson": "{\"v\":1,\"epoch\":2,\"removed\":[],\"destruct\":true,\"scheduled\":true,\"keys\":{}}"
632
+ }
633
+ },
634
+ "output": {
635
+ "encoder": {
636
+ "withRemoval": "refused",
637
+ "withScheduled": "refused"
638
+ },
639
+ "withRemoval": {
640
+ "notice": {
641
+ "epoch": 2,
642
+ "removed": [
643
+ "e795ed3fc0ba9cd6635f68e50a6123c15fb0b947b4030ae40b2768b08e27b827"
644
+ ],
645
+ "closed": false,
646
+ "at": 1800000000
647
+ },
648
+ "evidence": {
649
+ "epoch": 2,
650
+ "removed": [
651
+ "e795ed3fc0ba9cd6635f68e50a6123c15fb0b947b4030ae40b2768b08e27b827"
652
+ ],
653
+ "closed": false
654
+ }
655
+ },
656
+ "withScheduled": {
657
+ "notice": {
658
+ "epoch": 2,
659
+ "removed": [],
660
+ "closed": false,
661
+ "scheduled": true,
662
+ "at": 1800000000
663
+ },
664
+ "evidence": {
665
+ "epoch": 2,
666
+ "removed": [],
667
+ "closed": false,
668
+ "scheduled": true
669
+ }
670
+ }
671
+ }
672
+ }
673
+ ]
674
+ }
675
+ }