@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 +357 -0
- package/EXTRACTION.md +549 -0
- package/README.md +198 -15
- package/dist/epoch.d.ts +28 -1
- package/dist/epoch.js +99 -60
- package/dist/index.d.ts +7 -2
- package/dist/index.js +5 -1
- package/dist/invitation-decline.d.ts +26 -0
- package/dist/invitation-decline.js +74 -0
- package/dist/invitation.d.ts +61 -5
- package/dist/invitation.js +300 -57
- package/dist/live-persistent-admission.d.ts +52 -0
- package/dist/live-persistent-admission.js +183 -0
- package/dist/mls-admission.d.ts +79 -0
- package/dist/mls-admission.js +400 -0
- package/dist/persistent-invitation-evidence.d.ts +83 -0
- package/dist/persistent-invitation-evidence.js +381 -0
- package/dist/quiet-reply.d.ts +45 -0
- package/dist/quiet-reply.js +82 -0
- package/docs/extraction-plan-excerpt.md +56 -0
- package/docs/live-persistent-admission.md +192 -0
- package/docs/member-epoch-catch-up.md +346 -0
- package/docs/mls-admission-v4.md +84 -0
- package/docs/persistent-invitation-evidence.md +135 -0
- package/docs/quiet-replies.md +81 -0
- package/docs/room-destruct.md +142 -0
- package/docs/scheduled-rekey.md +142 -0
- package/docs/seal-key.md +104 -0
- package/llms.txt +97 -9
- package/package.json +24 -4
- package/vectors/fold-vectors.json +894 -0
- package/vectors/invitation-account-vectors.json +134 -0
- package/vectors/invitation-decline-vectors.json +110 -0
- package/vectors/live-persistent-vectors.json +1240 -0
- package/vectors/mls-admission-v4.json +25 -0
package/llms.txt
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Circle primitives for private Nostr groups nobody operates: a room key per epoch, sealed to each device that stays; link invitations as capabilities; member removal by rekey; device credentials so a person approves a device once and it signs from then on. Extracted from KithMoot, byte-identical on the wire. Framework-free: owns no storage, UI, relay defaults or identity keys, and does no relay I/O of its own.
|
|
4
4
|
|
|
5
|
-
ESM-only, Node.js 22.13+.
|
|
5
|
+
ESM-only, Node.js 22.13+. Four entry points: `@forgesworn/fold-kit` (circle primitives), `@forgesworn/fold-kit/lane` (relay lane classification only), `@forgesworn/fold-kit/mls-admission` (opt-in v4 evidence codec), and `@forgesworn/fold-kit/persistent-invitation-evidence` (bounded stored invitation observation and independent revalidation).
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -10,20 +10,68 @@ ESM-only, Node.js 22.13+. Two entry points: `@forgesworn/fold-kit` (everything)
|
|
|
10
10
|
npm install @forgesworn/fold-kit
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
This file describes `main` (version 0.12.0), which can run ahead of the npm `latest` release; compare `npm view @forgesworn/fold-kit version` with the version of the API you need. To use an unreleased change, pin an immutable Git commit (a full SHA, for example from `git ls-remote https://github.com/forgesworn/fold-kit main`); the `prepare` script builds `dist/` on install:
|
|
14
14
|
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
"dependencies": {
|
|
18
|
-
"@forgesworn/fold-kit": "github:forgesworn/fold-kit#<commit>"
|
|
19
|
-
}
|
|
20
|
-
}
|
|
15
|
+
```bash
|
|
16
|
+
npm install "github:forgesworn/fold-kit#<commit>"
|
|
21
17
|
```
|
|
22
18
|
|
|
23
19
|
### Peer dependencies
|
|
24
20
|
|
|
25
21
|
`nostr-tools` (`>=2.24.2 <3`), `@noble/hashes` (`^1.8.0`), `@noble/curves` (`^2.0.1`) - peers, not dependencies, so a consumer keeps a single copy of each. `@noble/hashes` is 1.x only: this kit's imports have no `.js` suffix and its `hkdf` calls pass a string `info`, both of which 2.x rejects.
|
|
26
22
|
|
|
23
|
+
```bash
|
|
24
|
+
npm install "nostr-tools@>=2.24.2 <3" "@noble/hashes@^1.8.0" "@noble/curves@^2.0.1"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
Derive a room, then publish and load a stored group invitation through a `RelayTransport` you supply (this one is an in-memory stub; a real transport talks to relays and applies the filters):
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import {
|
|
33
|
+
generateRoomSecret,
|
|
34
|
+
deriveRoom,
|
|
35
|
+
deriveEpoch,
|
|
36
|
+
localIdentity,
|
|
37
|
+
createRoomInvitation,
|
|
38
|
+
encodePersistentInvitation,
|
|
39
|
+
requestPersistentRoomAdmission,
|
|
40
|
+
type RelayTransport,
|
|
41
|
+
} from '@forgesworn/fold-kit'
|
|
42
|
+
import { generateSecretKey, type Event } from 'nostr-tools/pure'
|
|
43
|
+
|
|
44
|
+
// The person: a local signer. An external one (NIP-07, NIP-46) fits the same seam.
|
|
45
|
+
const me = localIdentity(generateSecretKey())
|
|
46
|
+
|
|
47
|
+
// The room: one secret opens epoch 0; later epochs come from rekeys.
|
|
48
|
+
const roomSecret = generateRoomSecret()
|
|
49
|
+
const { roomId, roomKey } = deriveRoom(roomSecret)
|
|
50
|
+
const epoch0 = deriveEpoch({ epoch: 0, secret: roomSecret }) // { epoch: 0, id: roomId, key: roomKey }
|
|
51
|
+
|
|
52
|
+
// A RelayTransport stub: stores events, replays them on subscribe, then signals EOSE.
|
|
53
|
+
const stored: Event[] = []
|
|
54
|
+
const transport: RelayTransport = {
|
|
55
|
+
async publish(event) { stored.push(event) },
|
|
56
|
+
subscribe(_filters, onEvent, onEose) {
|
|
57
|
+
queueMicrotask(() => { for (const event of stored) onEvent(event); onEose?.() })
|
|
58
|
+
return () => {}
|
|
59
|
+
},
|
|
60
|
+
close() {},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Invitation flow (v3 stored group invitation): the inviter publishes once, and a
|
|
64
|
+
// guest holding the link loads it from the relays with no responder online.
|
|
65
|
+
const { invitation, inviterSk } = createRoomInvitation(true)
|
|
66
|
+
await transport.publish(encodePersistentInvitation({
|
|
67
|
+
invitation, inviterSk, roomSecret, now: Math.floor(Date.now() / 1000),
|
|
68
|
+
}))
|
|
69
|
+
const admission = await requestPersistentRoomAdmission({ transport, invitation })
|
|
70
|
+
// admission.secret is the room secret: deriveRoom(admission.secret).roomId === roomId
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Invitation versions: v1 (`encodeJoinUrl`, the room secret in the link) is legacy, and new links go through `createRoomInvitation`. Use `createRoomInvitation()` for the v2 live rendezvous, which needs a responder online (`hostRoomInvitation` answers, `requestRoomAdmissionCapability` joins), or `createRoomInvitation(true)` for the v3 stored group invitation above, which needs none.
|
|
74
|
+
|
|
27
75
|
## Exports
|
|
28
76
|
|
|
29
77
|
### Identity, verification, hex
|
|
@@ -60,12 +108,20 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
|
|
|
60
108
|
- `parseRoomLink(url)` / `encodeRoomLink(base, link)` → `RoomLink` - the v1/v2/v3 link envelope
|
|
61
109
|
- `createRoomInvitation(persistent?)`, `roomInvitation(bearer, inviter, persistent?)`, `deriveInvitationId(invitation)`
|
|
62
110
|
- `encodeInvitationRequest(opts)` / `decodeInvitationRequest(event, opts)` - v2 live rendezvous request
|
|
111
|
+
- `encodeInvitationAccountProof({ invitation, device, identity, now })` - account signature bound to this invitation, ephemeral request device and exact timestamp. The optional proof remains inside the encrypted request; it grants no room access. `participant` alone is unverified. Automatic account-based admission must use decoded `verifiedParticipant`; invalid/missing proof never sets it.
|
|
63
112
|
- `encodeInvitationGrant(opts)` / `decodeRoomAdmissionGrant(event, opts)` - v2 grant, with a delegation chain
|
|
64
113
|
- `verifyInvitationDelegation(invitation, chain, now)`
|
|
65
114
|
- `encodeInvitationRetirement(opts)` / `decodeInvitationRetirementNotice(event, invitation)` - durable tombstone (kind 1461)
|
|
66
|
-
- `hostRoomInvitation(opts)` / `requestRoomAdmission(opts)` - the live desk, both sides
|
|
115
|
+
- `hostRoomInvitation(opts)` / `requestRoomAdmission(opts)` - the live desk, both sides. Opt-in quiet answering (see `docs/quiet-replies.md`): `grantDelayMs: [min, max]` waits before every grant (after `admit`); `coordinate: true` waits (`grantDelayMs`, default `[500, 2000]`) and stands down, at most once per request, if another key's grant (kind 20467, same `d`, `p` = requester) appears, then answers the retry without re-asking `admit`. `random`/`schedule` injectable. Absent, behaviour and subscriptions are as before
|
|
116
|
+
- `requestRoomAdmissionCapability(opts)` → `Promise<RoomAdmission>` retains responder delegation; `requestRoomAdmission(opts)` → `Promise<Uint8Array>`, its secret-only wrapper, resolves to just the room secret and discards it. Optional matching `identity` creates an account proof; signer errors reject without switching identities. Optional `signal` cancels. Signing and relay waiting share one deadline capped at 90 seconds. Late signatures/grants cannot revive cancelled/expired/retired requests. Caller-owned request keys are copied and preserved.
|
|
117
|
+
- `encodeInvitationDecline({ invitation, inviterSk, requester, request, now, delegation? })` → `Event` - a private, request-bound refusal from the pinned inviter or a current delegated responder (reply kind 20467, version-3 body `decision: 'declined'`). `requester` is the requester's pubkey and `request` the request event id, both hex. It carries no room secret or new delegation, and legacy version-2 grant readers ignore it. Throws `responder is not delegated for invitation` when `inviterSk` is neither the root nor the end of `delegation`. Main entry (0.12.0)
|
|
118
|
+
- `decodeInvitationDecline(event, { invitation, requesterSk, request, now, maxAgeSeconds? })` → `InvitationDecline | null` (`{ request, responder }`) - null unless the refusal is fresh (`maxAgeSeconds` defaults to 90), addressed to this requester, bound to this `request` and signed by the pinned root or a delegated responder; a bearer holder cannot refuse someone else. Main entry
|
|
119
|
+
- `InvitationDeclinedError` - no constructor arguments, message `Someone in the room declined your request to join.`; `requestRoomAdmissionCapability` stops retrying and rejects with it after verifying a refusal. Host `admit` hooks returning false still leave requests unanswered, so hosts must publish a refusal explicitly through their guarded, acknowledged decision path. Types `EncodeInvitationDeclineOptions`, `InvitationDecline`. Vectors: `vectors/invitation-decline-vectors.json`. No UI integration or physical acceptance is claimed
|
|
120
|
+
- Host `onGrantPublished(request)` / legacy `onAdmitted(device)` run after transport publication acknowledgement, not on approval. Hosts recheck request freshness after a decision. `onGrantFailed(request, error)` reports send failure or approval after expiry; closed hosts suppress outstanding callbacks. Publication does not prove guest receipt or room entry. Fresh duplicate signed requests resend the exact cached grant (with quiet delay), coalescing pending work without re-approval or new delegation. Success callbacks run once; a failed send may recover on retry. Up to 256 live decisions are retained, including declines; full capacity refuses new requests until settled entries expire. Every send rechecks request freshness and responder authority; close/retirement stops retries.
|
|
67
121
|
- `encodePersistentInvitation(opts)` / `decodePersistentInvitation(event, invitation)` - v3 stored group invitation (kind 1463)
|
|
68
122
|
- `requestPersistentRoomAdmission(opts)` - joins from stored admission alone, no live desk needed
|
|
123
|
+
- Optional `@forgesworn/fold-kit/persistent-invitation-evidence`: `requestPersistentRoomAdmissionEvidence(opts)` / `revalidatePersistentInvitationEvidence(opts)` - additive bounded canonical-carrier custody, exact invitation/event IDs, independently recomputed earliest end/sticky destruct/newest relays and contributor IDs. Equal-time conflicting newest lists, conflicting rooms and any observed retirement hold. EOSE is only local observation metadata, not currentness or global non-retirement. Incomplete queries reject with a bounded `PersistentInvitationEvidenceQueryError.observation` that cannot be revalidated as proof. See `docs/persistent-invitation-evidence.md`; protected persistence and client effect fences remain the caller's responsibility.
|
|
124
|
+
- Same optional entry: `revalidatePersistentInvitationSource({ event, invitation, expectedRoomSecret })` independently authenticates one actual encoder Event or exact canonical stored carrier against a mandatory room-root pin. Returns a frozen `PersistentInvitationSource` with `version`, `invitationId`, `inviter`, `eventId`, `createdAt`, `event` string and owned `admission`. No completion/EOSE, publication, newest-selection, currentness or non-retirement claim. Rejects retirement, unsupported bodies/tags, forged signature caches, noncanonical strings and oversize before parsing/crypto. Creator clients retain exact event bytes and publication intent before pools; retry those bytes under fresh effect guards. Erase returned admission.secret after use.
|
|
69
125
|
- 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
126
|
- 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
127
|
- 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
|
|
@@ -80,6 +136,7 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
|
|
|
80
136
|
- `encodeEpochGrant(opts)` / `decodeEpochGrant(event, opts)` - the authority's answer (secret, epoch-0, or a refusal `'removed' | 'closed'`)
|
|
81
137
|
- `epochRequestAdmission(opts)`, `deriveEpochRequestKey(roomKey)`
|
|
82
138
|
- `hostRoomEpoch(opts)` / `requestRoomEpoch(opts)` / `EpochRefusedError` - the live request/grant desk, both sides
|
|
139
|
+
- Quiet replies (see `docs/quiet-replies.md`): `hostRoomEpoch` waits a random `replyDelayMs` (`[min, max]` ms, default `DEFAULT_REPLY_DELAY_MS` = `[500, 2000]`, `[0, 0]` answers at once) before every grant or refusal, deciding when the wait ends; closing cancels waits. A participant is sent `removed`/`closed` at most once per `refuseAgainAfter` seconds (default `REFUSE_AGAIN_AFTER_SECONDS` = 3600) and `unknown` once per `reportUnknownEvery` (60); at most `maxRefusalsPerMinute` refusals (default `MAX_REFUSALS_PER_MINUTE` = 20) go out in any minute in all. Grants are never limited. A suppressed refusal still calls `onRefused`/`onUnknown`; its asker times out with the plain error. `random` and `schedule` (`ReplySchedule`) are injectable. `RefusalLimiter` (bounded to `REFUSAL_LIMITER_CAPACITY` = 256 keys) and `replyDelay(range, random)` are exported. Events and vectors are unchanged
|
|
83
140
|
- 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)
|
|
84
141
|
- 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
|
|
85
142
|
- Seal keys (see `docs/seal-key.md`): `createDeviceCredential({ ..., seal })` appends `["seal", <x-only pubkey>]` last, under the participant's signature; mint a fresh one per credential with `generateSealKey()` and keep its secret as you keep the device key (old copies are sealed to old seal keys; healing needs only that each new one is fresh). `encodeRekeyEvent` recipients may be `{ device, credential }`; `encodeEpochGrant` and `encodeMemberEpochGrant` take `credential`; each copy is sealed to `sealTarget(device, credential)`: the credential's seal key, else the device key (never back to the device key because the credential lapsed). Readers take `sealSks` (`decodeRekeyEvent`, `decodeEpochGrant`, `decodeMemberEpochGrant`) or a `sealSks()` getter (`requestRoomEpoch`, `memberEpochSource`) and try them before the device key (`openSealed`); a rekey copy nothing opens reads as no copy. Desks take `credentialFor(device)`, the newest credential the room has seen (keep it monotone), and seal to the newer of that and the presented one (`sealCredential`, `newerCredential`: later expiration, then later created_at). `credentialSeal(c)` is the pubkey, undefined for none, null when doubled or off the curve (sealed to the device key). Without seal keys every event is byte-identical to 0.6.0
|
|
@@ -102,6 +159,35 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
|
|
|
102
159
|
- `isLane(value)`, `weakestLane(lanes)`, `isDowngrade(requested, actual)`
|
|
103
160
|
- `LANES`, `LANE_MEANING`, `LANE_LABEL`, `LANE_GLYPH`
|
|
104
161
|
|
|
162
|
+
### Live persistent admission (main entry, opt-in codecs)
|
|
163
|
+
|
|
164
|
+
All from `@forgesworn/fold-kit`. `LivePersistentContext` is `{ invitation: RoomInvitation, roomId: string }`: a persistent (v3) invitation plus the room id from the separate routing descriptor. Encoders throw `invalid live persistent admission` on bad input; decoders return `null`.
|
|
165
|
+
|
|
166
|
+
- `parseLivePersistentEvent(json, request)` → `Event | null` - bounds raw event JSON (4096 bytes when `request` is true, else 20480) and refuses duplicate fields
|
|
167
|
+
- `encodeLivePersistentDescriptor({ invitation, roomId })` → `string` / `decodeLivePersistentDescriptor(encoded, invitation)` → `LivePersistentContext | null` - routing metadata (never the bearer) alongside an unchanged persistent invitation link
|
|
168
|
+
- `encodeLivePersistentRequest({ invitation, roomId, requesterSk, now })` → `Event` / `decodeLivePersistentRequest(event, { invitation, roomId, now })` → `LivePersistentRequest | null` (`{ requestId, requester, createdAt, expiresAt }`) - a fresh request, kind 20466, addressed to the root inviter
|
|
169
|
+
- `encodeLivePersistentAnswer({ invitation, roomId, request, invitationEvent, inviterSk, epoch, now })` → `Event` / `decodeLivePersistentAnswer(event, { invitation, roomId, request, requesterSk, now })` → `LivePersistentAnswer | null` (`{ admission, epochHint, requestId, expiresAt }`) - a root-signed answer, kind 20467, with no delegation. `admission` is the epoch-zero capability, kept separate from `epochHint`
|
|
170
|
+
- `LIVE_PERSISTENT_REQUEST_SECONDS` (90), `LIVE_PERSISTENT_RESPONSE_SECONDS` (30), `LIVE_PERSISTENT_ADMISSION_LABELS` (freezes its distinct KDF label); types `LivePersistentContext`, `LivePersistentRequest`, `LivePersistentAnswer`
|
|
171
|
+
|
|
172
|
+
These functions are codecs, not an offline join API. The host must have exclusive durable lifecycle authority; the client must complete authenticated epoch admission before publishing, including at epoch zero. No application enables this profile automatically. See [the wire profile and integration gates](docs/live-persistent-admission.md). Frozen synthetic fixtures are in `vectors/live-persistent-vectors.json`; run `npm run build && npm run generate-live-persistent` to regenerate them.
|
|
173
|
+
|
|
174
|
+
### MLS admission v4 (`@forgesworn/fold-kit/mls-admission`, opt-in evidence codec)
|
|
175
|
+
|
|
176
|
+
Import from `@forgesworn/fold-kit/mls-admission`; none of these is on the main entry. A separate pure evidence codec that binds an encrypted account/device request to signed keeper consent and an expected MLS group, on the existing 20466/20467 carriers with strict canonical arrays and a fixed payload bucket. Existing room links and codecs are unchanged. `core` and `proofs` inputs are canonical fixed-array JSON strings; outer events are compact JSON NIP-01 strings. Validation failures throw `invalid MLS admission v4`. See [API and boundaries](docs/mls-admission-v4.md).
|
|
177
|
+
|
|
178
|
+
- `encodeMlsInvitationV4(root, bearer)` → `string` - the link fragment for a root pubkey (hex) and a 32-byte bearer; the caller owns CSPRNG generation and secret lifetime
|
|
179
|
+
- `readMlsInvitationV4(fragment)` → `{ root, bearer, invitationId }`
|
|
180
|
+
- `mlsAdmissionProofTemplatesV4({ fragment, core, now, request? })` → `{ pubkey, event: UnsignedEvent }[]` - exact purpose-bound role templates; account/device signers stay caller-owned under real custody, and templates confer no authority. `core` is the exact canonical request array or, when `request` (a `VerifiedMlsAdmissionRequest`) is passed, the Allow array
|
|
181
|
+
- `encodeMlsAdmissionRequestV4({ fragment, core, proofs, requestSecretKey, now })` → `string`
|
|
182
|
+
- `decodeMlsAdmissionRequestV4({ fragment, event, localSecretKey, side, now })` → `VerifiedMlsAdmissionRequest` - `side` is `'root' | 'joiner'`; verifies every outer and inner signature afresh
|
|
183
|
+
- `readMlsAdmissionRequestV4(handle)` → `{ core, digest, eventId }` - immutable evidence strings, never authority
|
|
184
|
+
- `encodeMlsAdmissionAnswerV4({ fragment, request, core, proofs, rootSecretKey, now })` → `string`
|
|
185
|
+
- `decodeMlsAdmissionAnswerV4({ fragment, request, event, requestSecretKey, now })` → `VerifiedAdmissionAnswer`
|
|
186
|
+
- `readMlsAdmissionAnswerV4(handle)` → `{ decision, core, digest, eventId, requestEventId }` - `decision` is `'allow' | 'deny'`
|
|
187
|
+
- `MLS_ADMISSION_LABELS` (frozen v4 domain labels), `MLS_ADMISSION_PLAINTEXT_BYTES` (16384), `MLS_ADMISSION_CIPHERTEXT_CHARACTERS` (21936); types `VerifiedMlsAdmissionRequest`, `VerifiedAdmissionAnswer`
|
|
188
|
+
|
|
189
|
+
Only the decoders issue the nominal `Verified*` handles. Verified answers are evidence only: do not turn root or keeper signatures, or decoder handles, into admission authority. Actual admission needs purpose-specific vault proof, a fresh authenticated conditional box/Link keeper witness, current consent, custody and clock, a durable global counter and one-shot operation storage, VMLS policy and exact-group Welcome adoption. No transport, storage, browser join or automatic retry is implemented here.
|
|
190
|
+
|
|
105
191
|
## Design notes
|
|
106
192
|
|
|
107
193
|
- Every `kithmoot/v*` derivation label and message prefix is byte-identical to the source KithMoot commit this kit was extracted from (see EXTRACTION.md). These are protocol names now, not branding.
|
|
@@ -113,3 +199,5 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
|
|
|
113
199
|
- README.md - full API overview, peer dependency rationale, how this kit relates to `covey-kit` and `roost-kit`
|
|
114
200
|
- EXTRACTION.md - the source commit, exactly which modules and functions moved, and the two splits (`access.ts`, `chat.ts`) performed on the way
|
|
115
201
|
- AGENTS.md - build/test commands and repository conventions for an agent working in this repo
|
|
202
|
+
- CHANGELOG.md - behaviour changes, including how to handle `RestampedCredentialExpiryError`
|
|
203
|
+
- docs/ - wire profiles per feature (member epoch catch-up, seal keys, scheduled rekey, room destruct, live persistent admission, MLS admission v4, persistent invitation evidence)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgesworn/fold-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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",
|
|
@@ -14,7 +14,15 @@
|
|
|
14
14
|
"types": "./dist/lane.d.ts",
|
|
15
15
|
"default": "./dist/lane.js"
|
|
16
16
|
},
|
|
17
|
-
"./package.json": "./package.json"
|
|
17
|
+
"./package.json": "./package.json",
|
|
18
|
+
"./mls-admission": {
|
|
19
|
+
"types": "./dist/mls-admission.d.ts",
|
|
20
|
+
"default": "./dist/mls-admission.js"
|
|
21
|
+
},
|
|
22
|
+
"./persistent-invitation-evidence": {
|
|
23
|
+
"types": "./dist/persistent-invitation-evidence.d.ts",
|
|
24
|
+
"default": "./dist/persistent-invitation-evidence.js"
|
|
25
|
+
}
|
|
18
26
|
},
|
|
19
27
|
"files": [
|
|
20
28
|
"dist",
|
|
@@ -26,7 +34,15 @@
|
|
|
26
34
|
"vectors/destruct-vectors.json",
|
|
27
35
|
"LICENSE",
|
|
28
36
|
"README.md",
|
|
29
|
-
"llms.txt"
|
|
37
|
+
"llms.txt",
|
|
38
|
+
"vectors/live-persistent-vectors.json",
|
|
39
|
+
"vectors/invitation-account-vectors.json",
|
|
40
|
+
"vectors/invitation-decline-vectors.json",
|
|
41
|
+
"vectors/mls-admission-v4.json",
|
|
42
|
+
"vectors/fold-vectors.json",
|
|
43
|
+
"docs",
|
|
44
|
+
"EXTRACTION.md",
|
|
45
|
+
"CHANGELOG.md"
|
|
30
46
|
],
|
|
31
47
|
"scripts": {
|
|
32
48
|
"build": "tsc -p tsconfig.json",
|
|
@@ -44,7 +60,11 @@
|
|
|
44
60
|
"vectors": "vitest run vectors/",
|
|
45
61
|
"check": "npm run typecheck && npm test && npm run diff-source",
|
|
46
62
|
"prepare": "npm run build",
|
|
47
|
-
"prepublishOnly": "npm run check && npm run bundle-check && npm run tarball-smoke"
|
|
63
|
+
"prepublishOnly": "npm run check && npm run bundle-check && npm run tarball-smoke",
|
|
64
|
+
"generate-live-persistent": "node scripts/generate-live-persistent.mjs",
|
|
65
|
+
"generate-invitation-account": "node scripts/generate-invitation-account.mjs",
|
|
66
|
+
"generate-invitation-decline": "node scripts/generate-invitation-decline.mjs",
|
|
67
|
+
"generate-mls-admission": "node scripts/generate-mls-admission.mjs"
|
|
48
68
|
},
|
|
49
69
|
"keywords": [
|
|
50
70
|
"nostr",
|