@forgesworn/fold-kit 0.9.0 → 0.12.0

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