@forgesworn/fold-kit 0.10.0 → 0.13.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/CHANGELOG.md ADDED
@@ -0,0 +1,371 @@
1
+ # Changelog
2
+
3
+ This kit is pre-1.0 (see AGENTS.md "Release Notes"); a behaviour change on
4
+ `main` is still called out here, because `createDeviceCredential` is a
5
+ byte-identical copy of a KithMoot function (see EXTRACTION.md) and this is
6
+ the one place its behaviour has deliberately diverged.
7
+
8
+ ## 0.13.0
9
+
10
+ ### Added
11
+
12
+ - One-time senders for rekeys (1462) and authority grants (20469):
13
+ `oneTimeSender` on `encodeRekeyEvent` and `encodeEpochGrant` seals every
14
+ copy from a fresh key named in a `from` tag, so a later copy of the
15
+ authority's key no longer opens past copies. Off by default; off, events
16
+ are byte-identical to 0.12.0. Readers (`decodeRekeyEvent`,
17
+ `decodeEpochGrant`) open either form now, through the new `sealSender`
18
+ (also exported, with `SENDER_TAG`). Turn writers on only once these readers
19
+ have shipped everywhere. See docs/seal-key.md, "One-time senders"; vectors
20
+ in `seal-vectors.json`.
21
+
22
+ ## 0.12.0
23
+
24
+ ### Fixed
25
+
26
+ - Live invitation hosts resend the same signed grant on a fresh duplicate
27
+ request. This repairs a lost ephemeral grant, including when its first
28
+ publication precedes the guest's subscription. Approval and successful
29
+ publication observers run once; concurrent retries coalesce. Retry sends
30
+ retain quiet delays and recheck freshness and authority. The 256-entry
31
+ decision cache retains pending and declined requests rather than evicting
32
+ live decisions; close and retirement stop outstanding work.
33
+
34
+ ### Changed
35
+
36
+ - `hostRoomEpoch` answers quietly by default: every grant and refusal waits
37
+ a random `replyDelayMs` (default `[500, 2000]` ms), a participant is sent
38
+ a `removed` or `closed` refusal at most once per `refuseAgainAfter`
39
+ (default 3600 s) and `unknown` at most once per `reportUnknownEvery`
40
+ (60 s), and no more than `maxRefusalsPerMinute` (20) refusals go out in
41
+ any minute. Grants are never limited. A removed member holding the room
42
+ key could otherwise ask with fresh requests and time each signed reply.
43
+ A requester refused inside the window now times out with the plain
44
+ "not answering" error instead of `EpochRefusedError`. Callers and tests
45
+ that need the old instant reply pass `replyDelayMs: [0, 0]`.
46
+
47
+ ### Added
48
+
49
+ - `hostRoomInvitation` options `grantDelayMs` and `coordinate`, off by
50
+ default: a jittered wait before automatic grants, and a coordinated mode
51
+ in which delegated devices stand down when another responder's grant to
52
+ the same requester appears. `random` and `schedule` are injectable on both
53
+ desks.
54
+ - `RefusalLimiter`, `replyDelay`, `DEFAULT_REPLY_DELAY_MS`,
55
+ `REFUSE_AGAIN_AFTER_SECONDS`, `MAX_REFUSALS_PER_MINUTE`,
56
+ `REFUSAL_LIMITER_CAPACITY`, and the `ReplyDelayRange`, `ReplySchedule`
57
+ and `RefusalLimiterOptions` types. See `docs/quiet-replies.md`.
58
+
59
+ The retry repair and quiet-reply controls leave existing events, tags, kinds
60
+ and vectors unchanged. Consumers upgrading from npm 0.10.0 also receive the
61
+ account-signed invitation proofs and bounded request lifecycle described under
62
+ 0.11.0 below; 0.11.0 was not published to npm.
63
+
64
+ ## 0.11.0
65
+
66
+ ### Added
67
+
68
+ - Optional account-signed invitation proofs, bound to the invitation, pinned
69
+ inviter, request device and timestamp. `encodeInvitationAccountProof` checks
70
+ the matching signer's returned signature and context. The proof remains
71
+ inside the bearer-encrypted request; it must never be published separately.
72
+ - `InvitationRequest.verifiedParticipant`, set only after fresh verification.
73
+ `participant` remains an unverified claim. Consumers must use the verified
74
+ field for automatic admission based on an invited account.
75
+ - Matching `identity` and cancellation `signal` options for
76
+ `requestRoomAdmissionCapability`, with known-answer proof vectors and a
77
+ packaged-consumer proof round trip.
78
+
79
+ ### Changed
80
+
81
+ - Signing and relay waiting share one deadline capped at the 90-second request
82
+ freshness window. Cancellation, retirement and expiry suppress late signing
83
+ and retries; a host rechecks freshness after an awaited admission decision.
84
+ - Request helpers clone and erase their own exchange key while preserving the
85
+ caller's key and the admitted responder's retained delegation key.
86
+ - Browser size budgets cover measured growth of 2,725 minified bytes and
87
+ 729 gzip bytes over 0.10, with no new dependency. CI checks browser bundles
88
+ and a real installed tarball as well as protocol/unit tests.
89
+
90
+ Without an account proof, the existing request and grant wire formats remain
91
+ unchanged. This additive API does not repair a consumer that still trusts an
92
+ unverified `participant` claim.
93
+
94
+ ## 0.9.0
95
+
96
+ ### Added
97
+
98
+ - Self-destructing rooms (`docs/room-destruct.md`):
99
+ `encodePersistentInvitation({ destruct: true })` writes `"destruct": true`
100
+ inside the encrypted group invitation body, after `ends`; `ends` is not
101
+ required. `decodePersistentInvitation` returns
102
+ `PersistentRoomAdmission.destruct` and refuses the envelope when the field
103
+ is present and not `true`. `requestPersistentRoomAdmission` keeps
104
+ `destruct` if any valid signed copy carries it, in either order, as it
105
+ keeps the earliest end.
106
+ - `encodeInvitationRetirement({ ended: true, destruct: true })` writes
107
+ `{"v":1,"ended":true,"destruct":true}` and throws on `destruct` without
108
+ `ended`. `decodeInvitationRetirementNotice` now returns
109
+ `{ ended: boolean; destruct?: true }`, believing `destruct` only beside
110
+ `ended`. The retirement content is plain JSON, as `ended` always was.
111
+ - `encodeRekeyEvent({ closed: true, destruct: true })` writes
112
+ `"destruct": true` inside the encrypted body, after `closed`, and throws
113
+ on `destruct` without `closed`. `RekeyNotice.destruct`
114
+ (`decodeRekeyEvent`) and `RekeyEvidence.destruct` (`readRekeyEvidence`)
115
+ report it only on a closing body; on an open rekey the flag is dropped
116
+ and the rekey still read.
117
+ - Vectors: `vectors/destruct-vectors.json` (`npm run generate-destruct`).
118
+
119
+ ### Changed
120
+
121
+ - `decodePersistentInvitation` refuses a group invitation whose body
122
+ carries `destruct` as anything but `true`. No released writer produces
123
+ one.
124
+
125
+ Without the flag, or with it `false`, every event is byte-identical to
126
+ 0.8.0's. A 0.8.0 reader ignores the flag: it admits to a self-destructing
127
+ room, reads its retirement as an ended room and its closure as a close, and
128
+ ends the room the old way.
129
+
130
+ ## 0.8.0
131
+
132
+ ### Added
133
+
134
+ - Scheduled rekeys (`docs/scheduled-rekey.md`): `encodeRekeyEvent({
135
+ scheduled: true })` writes `"scheduled": true` inside the encrypted rekey
136
+ body, so a client can move to the new epoch without announcing it. The
137
+ encoder throws when it is combined with a removal or a close.
138
+ `RekeyNotice.scheduled` (`decodeRekeyEvent`) and `RekeyEvidence.scheduled`
139
+ (`readRekeyEvidence`) report it only when nobody was removed and the room
140
+ stays open.
141
+ - The history window: `HISTORY_WINDOW_SECONDS` (30 days),
142
+ `MAX_HISTORY_EPOCHS` (16) and `epochsInWindow(left, now)`, the left epochs
143
+ a member goes on reading: those left within the window, newest first, at
144
+ most 16.
145
+ - The authority's grant carries the window. `encodeEpochGrant({ passed })`
146
+ writes the left epochs (`LeftEpoch`: `{ epoch, secret, leftAt }`) as
147
+ `passed: [{ epoch, secret, left }]`, oldest first, and throws on more than
148
+ 16, epoch 0, an epoch not below the one granted, a doubled epoch, a bad
149
+ secret or a bad time. `hostRoomEpoch({ past })` asks for them on every
150
+ grant, keeps the window's and leaves out anything the encoder would refuse.
151
+ `decodeEpochGrant` returns them as `EpochGrant.passed`, and drops a
152
+ malformed list while keeping the grant.
153
+ - Vectors: `vectors/schedule-vectors.json` (`npm run generate-schedule`).
154
+
155
+ ### Changed
156
+
157
+ - `EpochGrant.passed` and `MemberEpochGrant.passed` are now
158
+ `Array<RoomEpoch & { leftAt?: number }>`, and `decodeMemberEpochGrant`
159
+ sets `leftAt` from the `created_at` of the authority's rekey out of each
160
+ passed epoch.
161
+
162
+ A rekey without the marker, and a grant without `passed`, is byte-identical
163
+ to 0.7.0's. A 0.7.0 reader ignores both fields: it announces a scheduled
164
+ rekey as any other, and reads the current epoch from a grant that carries
165
+ the window.
166
+
167
+ ## 0.7.0
168
+
169
+ ### Added
170
+
171
+ - Seal keys (`docs/seal-key.md`): healing after a device key is copied.
172
+ `createDeviceCredential({ seal })` appends `["seal", <x-only pubkey>]` to
173
+ the credential, under the participant's signature. `encodeRekeyEvent`
174
+ takes recipients as `{ device, credential }` as well as bare devices
175
+ (`RekeyRecipient`), `encodeEpochGrant` and `encodeMemberEpochGrant` take
176
+ `credential`, and each copy is sealed to that credential's seal key when it
177
+ names one. `decodeRekeyEvent`, `decodeEpochGrant` and
178
+ `decodeMemberEpochGrant` take `sealSks`, and `requestRoomEpoch` and
179
+ `memberEpochSource` take a `sealSks()` getter; each tries the seal keys,
180
+ then the device key. `hostRoomEpoch` and `hostMemberEpochDesk` take
181
+ `credentialFor(device)` and seal each answer to the newer of that and the
182
+ credential the request carries (`sealCredential`). New in `seal.ts`:
183
+ `SEAL_TAG`, `isSealPubkey`, `generateSealKey`, `credentialSeal`,
184
+ `sealTarget`, `sealTo`, `openSealed`, `newerCredential`.
185
+ - Vectors: `vectors/seal-vectors.json` (`npm run generate-seal`).
186
+
187
+ ### Changed
188
+
189
+ - `decodeRekeyEvent`: a copy for this device that none of its keys opens is
190
+ read as no copy (the notice without `secret`), rather than the whole rekey
191
+ reading as null. A session asked the authority either way; it now also
192
+ learns who was removed.
193
+
194
+ With no seal key anywhere, every event is byte-identical to 0.6.0's.
195
+
196
+ ## 0.6.0
197
+
198
+ ### Changed
199
+
200
+ - The known-members gate (#207). The epoch desks' admission proof is made
201
+ under the epoch-0 room key, which a removed member keeps, so a removed
202
+ person could ask again under a fresh participant key and be handed the
203
+ current epoch. Once a room has removed anybody, `hostRoomEpoch` and
204
+ `hostMemberEpochDesk` now grant only to participants for which the new
205
+ `known(participant)` option says yes, and report anybody else through
206
+ `onUnknown`: once per participant, and again every `reportUnknownEvery`
207
+ seconds (default 60) while they keep asking. **A desk given no `known` lets nobody through after a
208
+ removal**: wire `known` before taking this release. Rooms that have
209
+ never removed anybody behave exactly as before.
210
+ - New refusal `'unknown'` (`EpochRefusal`): the authority's answer to a
211
+ participant it does not know. Not final: `requestRoomEpoch` keeps asking,
212
+ calls its new `onUnknown` once, and rejects with
213
+ `EpochRefusedError('unknown')` only at its timeout. A decoder from 0.5.1
214
+ reads it as no answer and keeps asking.
215
+
216
+ ### Added
217
+
218
+ - `encodeRekeyEvent({ members })`: the authority's member list in the
219
+ encrypted rekey body (after `commit`, before `keys`), removed
220
+ participants dropped. Read as `RekeyNotice.members`,
221
+ `RekeyEvidence.members` and `MemberEpochGrant.members`.
222
+ `hostRoomEpoch({ members })` and `encodeEpochGrant({ members })` carry it
223
+ in the authority's grant (`EpochGrant.members`). `readMemberList`.
224
+ Omitted, every event is byte-identical to 0.5.1's.
225
+ - Vector `rekey-with-members` in `vectors/member-epoch-vectors.json`.
226
+
227
+ ## 0.5.1
228
+
229
+ ### Added
230
+
231
+ - `MemberEpochGrant.passed`: the epochs a member grant carried between the
232
+ requester's and the one it hands over, oldest first, each proven by the
233
+ next rekey in the chain as before. `requestRoomEpoch`'s grant carries it
234
+ as an optional `passed` when a member answered. Wire unchanged; a
235
+ consumer that ignores it behaves exactly as with 0.5.0. KithMoot uses it
236
+ to read what was said in the epochs a returning device skipped, rather
237
+ than reporting them lost.
238
+
239
+ ## 0.5.0
240
+
241
+ ### Added
242
+
243
+ - Member epoch catch-up: any current member can bring an admitted,
244
+ non-removed device up to date while the authority is offline, and the
245
+ device verifies the answer against the authority's signatures instead of
246
+ trusting the member. See `docs/member-epoch-catch-up.md`.
247
+ - `encodeRekeyEvent` takes an optional `commit: true`, which writes
248
+ `commit: epochCommitment(roomId, epoch, secret)` into the encrypted
249
+ body (key order `v, epoch, removed, by, closed, commit, keys`; the body
250
+ version stays 1 and a 0.4.0 reader ignores the key). Without it the
251
+ event is byte-identical to 0.4.0's.
252
+ - New module `epoch-commit.ts`: `epochCommitment`, `EPOCH_COMMIT_PREFIX`.
253
+ - New module `member-epoch.ts`: kinds 20471 (member epoch request) and
254
+ 20472 (member epoch grant) as `MEMBER_EPOCH_KINDS`;
255
+ `hostMemberEpochDesk`, `memberEpochSource`, `requestMemberEpoch`,
256
+ `encodeMemberEpochRequest`, `decodeMemberEpochRequest`,
257
+ `encodeMemberEpochGrant`, `decodeMemberEpochGrant`, `readRekeyEvidence`,
258
+ `deriveMemberEpochRequestKey`, `MAX_MEMBER_EPOCH_CHAIN`.
259
+ - `requestRoomEpoch` takes an optional `members` source; without it, it
260
+ behaves exactly as before.
261
+ - `memberEpochSource` watches the room's rekeys and refuses a member
262
+ grant that stops short of the newest authority-signed one, so a member
263
+ removed at epoch E cannot hold a requester at E-1 when the caller
264
+ passes no `expected`.
265
+ - Member grants (20472) are signed by a one-time key per grant, not the
266
+ answering member's device key, so a grant does not publicly tie that
267
+ device to the room. `MemberEpochGrant` has no `from`, and
268
+ `encodeMemberEpochGrant` takes no `deviceSk`.
269
+ - `memberEpochSource`'s `removed` is a function or an iterable read once,
270
+ so a generator is not used up by the first grant.
271
+ - Two new wire labels: `kithmoot/v1/epoch-commit:` and
272
+ `kithmoot/v1/member-epoch-request-key`.
273
+ - `vectors/member-epoch-vectors.json` (KithMoot vector format, shipped in
274
+ the package) and `scripts/generate-member-epoch.mjs`.
275
+ - The browser bundle budget for the main entry rises to 64 KB minified /
276
+ 19 KB gzip (measured 51.2 / 14.9).
277
+
278
+ ## 0.4.0
279
+
280
+ ### Added
281
+
282
+ - Room relays: the relays a room is created on, carried in its signed group
283
+ invitation so every member's pool includes them and two members can never
284
+ end up on disjoint relays. `encodePersistentInvitation` takes an optional
285
+ `relays`: one to eight distinct strings, each a safe relay URL
286
+ (`isSafeRelayUrl`) in canonical form (`normalizeURL` from
287
+ `nostr-tools/utils`, e.g. `wss://relay.example.com/`) with no
288
+ credentials, else it throws. The encrypted v3 body carries them after
289
+ `ends` (key order `v, room, secret, ends, relays`; the version stays 3).
290
+ `decodePersistentInvitation` returns `relays` (and
291
+ `PersistentRoomAdmission` gains it) and returns null for the whole
292
+ envelope when the list is malformed - never a trimmed list.
293
+ `requestPersistentRoomAdmission` keeps the relays of the newest signed
294
+ copy (by `created_at`) that names any; a copy naming none says nothing
295
+ about them, and between equal timestamps the first heard stays. Contrast
296
+ `endsAt`, where the earliest end wins.
297
+ - `isInvitationRelays`, `requireInvitationRelays` and
298
+ `MAX_INVITATION_RELAYS` (8): the rule, for callers that build the list.
299
+
300
+ With no `relays`, the body is byte-identical to 0.3.0's, and a 0.3.0
301
+ reader ignores the key. `scripts/diff-source.mjs` declares each added line
302
+ (see EXTRACTION.md "Room relays").
303
+
304
+ ## 0.3.0
305
+
306
+ ### Added
307
+
308
+ - Conference rooms: a persistent group that ends on a fixed date.
309
+ `encodePersistentInvitation` takes an optional `endsAt` (unix seconds,
310
+ after `now` and no more than 30 days beyond it, else it throws). With it,
311
+ the encrypted v3 body carries `ends` (the version stays 3) and the kind
312
+ 1463 event carries a NIP-40 `['expiration', String(endsAt)]` tag, so
313
+ relays drop the invitation when the room ends. `decodePersistentInvitation`
314
+ returns `endsAt` (and `PersistentRoomAdmission` gains it) when the body
315
+ has a positive whole-number `ends`; it returns null when an `expiration`
316
+ tag is present and does not equal the body's `ends`, when there are two
317
+ `expiration` tags, or when `ends` is malformed. `requestPersistentRoomAdmission`
318
+ keeps the earliest `endsAt` among the signed copies it hears.
319
+ - `encodeInvitationRetirement` takes `endsAt`, tagging the kind 1461
320
+ tombstone with the same expiration.
321
+ - `encodeRekeyEvent`, `encodeEpochRequest`, `encodeEpochGrant`,
322
+ `hostRoomEpoch` and `requestRoomEpoch` take `expiresAt`, so a conference
323
+ room's epoch events lapse with it.
324
+ - `withExpiration(tags, expiresAt)`, `isRoomEnds`, `requireRoomEnds` and
325
+ `MAX_ROOM_ENDS_SECONDS`: the expiration rule every event signed for a
326
+ conference room follows - add the end if the event has no expiration,
327
+ keep an earlier one, lower a later one, never two.
328
+
329
+ With no `endsAt`/`expiresAt`, every encoder produces exactly the bytes it
330
+ did in 0.2.0. `scripts/diff-source.mjs` declares each added line (see
331
+ EXTRACTION.md "Conference rooms").
332
+
333
+ ## 0.2.0
334
+
335
+ ### Added
336
+
337
+ - `deriveScoped(epoch, label)` for app-defined keys under an epoch, and
338
+ `createSubKeyCertificate` / `verifySubKeyCertificate` for never-published
339
+ sub-key certificates (see README and `vectors/fold-vectors.json`).
340
+
341
+ ### Changed (breaking behaviour, pre-1.0)
342
+
343
+ - **`createDeviceCredential` now throws for a person-scope credential a
344
+ restamping signer would make unverifiable**
345
+ ([forgesworn/kithmoot#205](https://github.com/forgesworn/kithmoot/issues/205)).
346
+ Previously, a remote signer that restamped `created_at` to an earlier
347
+ clock could make `createDeviceCredential` return a credential that every
348
+ `verifyDeviceCredential` call would then refuse as `"longer than 30
349
+ days"` - the mint-time cap was checked against the requested `now`, while
350
+ the verifier checks against the signed `created_at`. `createDeviceCredential`
351
+ now re-checks with the verifier's own measurement and throws a new typed
352
+ error, `RestampedCredentialExpiryError` (exported from the package root),
353
+ instead of returning a credential doomed to fail everywhere.
354
+ - **Who is affected**: only callers passing `scope: 'person'` whose
355
+ `identity.signEvent` may restamp `created_at` (a NIP-46 bunker, a NIP-55
356
+ phone signer, or any other out-of-process signer) AND whose requested
357
+ `expiresAt` is close enough to `now + 30 days` that the restamp pushes
358
+ the real duration over the cap. A local signer (`localIdentity`), or any
359
+ signer that does not restamp, is unaffected. Room-scope credentials
360
+ (`roomId` set, no `scope`) have no 30-day cap and are unaffected.
361
+ - **Migration**: catch `RestampedCredentialExpiryError` and retry with a
362
+ shorter `expiresAt` margin (this kit's own vectors use a one-hour
363
+ margin: `now + PERSON_CREDENTIAL_MAX_SECONDS - 3600`), as the downstream
364
+ sync spec that reported this issue already does.
365
+ - **KithMoot pairing required**: KithMoot has cut over to this kit and
366
+ pins `@forgesworn/fold-kit` exactly, so its `src/credential.ts` is a
367
+ re-export and picks this fix up on its next version bump. That bump must
368
+ land in the same KithMoot PR as the matching change to its
369
+ `vectors/verify-circle.test.ts` `refused-over-30-days` case (M15) and
370
+ `vectors/generate-circle.mjs`, which still expect the mint to return the
371
+ over-cap event rather than throw.