@forgesworn/fold-kit 0.0.0-seed.0 → 0.1.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.
@@ -0,0 +1,24 @@
1
+ import type { Event } from 'nostr-tools/pure';
2
+ import type { Filter } from 'nostr-tools/filter';
3
+ /**
4
+ * The relay transport seam the kit's codecs need: publish, subscribe, close.
5
+ *
6
+ * This is the kit's own declaration, not an import of KithMoot's
7
+ * `RelayTransport` (KithMoot's `src/relay-pool.ts`). The relay pool itself
8
+ * has not moved to the kit yet (see EXTRACTION.md): epoch, invitation and
9
+ * persistent-invitation only ever call `publish`/`subscribe`/`close` on the
10
+ * transport they are given, so the kit needs only that much of the shape,
11
+ * not KithMoot's richer `describe()`/`rekey()` extensions.
12
+ *
13
+ * A caller can pass KithMoot's `NostrRelayPool` (which implements a superset
14
+ * of this interface) straight through, or the kit's own `SimTransport`
15
+ * (`test/sim-relay.ts`), or any other implementation with the same three
16
+ * methods.
17
+ */
18
+ export interface RelayTransport {
19
+ publish(event: Event): Promise<void>;
20
+ /** `via` is the relay URL that delivered the event, when the transport
21
+ * knows it. */
22
+ subscribe(filters: Filter[], onEvent: (event: Event, via?: string) => void, onEose?: () => void): () => void;
23
+ close(): void;
24
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ import type { Event } from 'nostr-tools/pure';
2
+ /** Circle-layer wire types: a subset of KithMoot's `src/types.ts`, copied
3
+ * byte-identical per declaration from the pinned source commit (see
4
+ * EXTRACTION.md). KithMoot's `types.ts` also defines roster, media and
5
+ * descriptor types that stay in KithMoot; those import `Reachability`
6
+ * (KithMoot's `src/reachability.ts`), which does not move here (see
7
+ * EXTRACTION.md's "What did not move" section). None of the types below
8
+ * need it. */
9
+ /** A device credential is an ordinary signed Nostr event, never published bare. */
10
+ export type DeviceCredential = Event;
11
+ /** Kindred tiers, closest first: family, mutual verified bond, one-way
12
+ * recognition, no requirement at all. */
13
+ export type AccessTier = 'open' | 'ken' | 'kith' | 'kin';
14
+ /** What a room requires of its agents. `owned-by-members`: an agent is
15
+ * admitted to the roster only with a verified ownership proof from a
16
+ * participant who is in the room. See `AgentOwnership`. */
17
+ export type AgentRule = 'owned-by-members';
18
+ /** A room's admission rule. `admitted` lists the issuer pubkeys the room
19
+ * trusts to vouch for guests; irrelevant when `tier` is `open`. `agents`
20
+ * is a separate rule about what an agent has to show; absent means
21
+ * nothing, which is how every room worked before it existed. */
22
+ export interface RoomPolicy {
23
+ tier: AccessTier;
24
+ admitted?: string[];
25
+ agents?: AgentRule;
26
+ /** When present, the only participants admitted, whatever the tier
27
+ * says. A direct message is a room whose policy lists two. See
28
+ * `docs/messages.md`. */
29
+ members?: string[];
30
+ /** A quiet room: its chat rides the kind 1059 firehose as dead drops to
31
+ * keys derived from the epoch key, on a cadence, so a relay cannot tell
32
+ * whether anything was said, by whom, or when. Only with `members`,
33
+ * because everybody derives every member's keys. Rides here so that
34
+ * everyone who joins agrees on how the room talks, exactly as they
35
+ * agree on who may enter. See `quiet.ts`. */
36
+ quiet?: true;
37
+ }
38
+ /**
39
+ * A signed claim that `issuer` recognises `participant` at `tier` in `room`,
40
+ * until `expiresAt`. Never issued for `open`, since open needs no proof.
41
+ *
42
+ * `room` is what stops a proof being a bearer token: without it, one proof
43
+ * admits its holder to every room that happens to trust the same issuer, and
44
+ * an issuer who vouched for a guest at one moot has not vouched for them at
45
+ * all of them. The cost of that binding is stated plainly: a kindred proof is
46
+ * a room grant here, not a portable statement about a relationship, so an
47
+ * issuer mints one per room. In this protocol the party who vouches is the
48
+ * party who sent the join link, so it already knows the room id.
49
+ */
50
+ export type KindredProof = {
51
+ tier: Exclude<AccessTier, 'open'>;
52
+ participant: string;
53
+ issuer: string;
54
+ /** The room id this proof is valid in. */
55
+ room: string;
56
+ /** 32 random bytes, hex, unique to this proof. Signed over, so two proofs
57
+ * on identical terms are still distinguishable - which is what a revocation
58
+ * list, or an audit, needs to name one of them. */
59
+ nonce: string;
60
+ sig: string;
61
+ expiresAt: number;
62
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { type Event } from 'nostr-tools/pure';
2
+ /**
3
+ * Verify an event's signature, ignoring any cached verdict it arrives with.
4
+ *
5
+ * `verifyEvent` caches its result on the event object under `verifiedSymbol`
6
+ * and returns that cached value on any later call, without re-checking
7
+ * anything. The property is an own enumerable symbol, so it survives a
8
+ * shallow copy (`{ ...event }`, which is exactly how a caller might rebuild
9
+ * an event after editing a field) - and an attacker who hands us an object
10
+ * rather than a JSON string can simply set it themselves. Either way the
11
+ * signature check silently becomes a no-op.
12
+ *
13
+ * Every inbound event in this library goes through here rather than calling
14
+ * `verifyEvent` directly, so the defence cannot be forgotten by the next
15
+ * decoder somebody writes. The copy also keeps the caller's object
16
+ * unmodified: we never stamp a verdict onto something we did not sign.
17
+ */
18
+ export declare function verifyEventUncached(event: Event): boolean;
19
+ /**
20
+ * Keep relay fan-out from verifying the same signed event once per relay and
21
+ * replay. The cached value includes every signed field, so an object that
22
+ * reuses a known id while changing its body or signature is rejected. The
23
+ * fixed-size FIFO also prevents an untrusted relay from
24
+ * growing keeper memory without bound.
25
+ */
26
+ export declare function boundedEventVerifier(limit?: number, verify?: (event: Event) => boolean): (event: Event) => boolean;
package/dist/verify.js ADDED
@@ -0,0 +1,46 @@
1
+ import { verifyEvent, verifiedSymbol } from 'nostr-tools/pure';
2
+ /**
3
+ * Verify an event's signature, ignoring any cached verdict it arrives with.
4
+ *
5
+ * `verifyEvent` caches its result on the event object under `verifiedSymbol`
6
+ * and returns that cached value on any later call, without re-checking
7
+ * anything. The property is an own enumerable symbol, so it survives a
8
+ * shallow copy (`{ ...event }`, which is exactly how a caller might rebuild
9
+ * an event after editing a field) - and an attacker who hands us an object
10
+ * rather than a JSON string can simply set it themselves. Either way the
11
+ * signature check silently becomes a no-op.
12
+ *
13
+ * Every inbound event in this library goes through here rather than calling
14
+ * `verifyEvent` directly, so the defence cannot be forgotten by the next
15
+ * decoder somebody writes. The copy also keeps the caller's object
16
+ * unmodified: we never stamp a verdict onto something we did not sign.
17
+ */
18
+ export function verifyEventUncached(event) {
19
+ const unverified = { ...event };
20
+ delete unverified[verifiedSymbol];
21
+ return verifyEvent(unverified);
22
+ }
23
+ /**
24
+ * Keep relay fan-out from verifying the same signed event once per relay and
25
+ * replay. The cached value includes every signed field, so an object that
26
+ * reuses a known id while changing its body or signature is rejected. The
27
+ * fixed-size FIFO also prevents an untrusted relay from
28
+ * growing keeper memory without bound.
29
+ */
30
+ export function boundedEventVerifier(limit = 8_192, verify = verifyEventUncached) {
31
+ if (!Number.isSafeInteger(limit) || limit < 1)
32
+ throw new Error('Event verification cache size must be a positive integer');
33
+ const verified = new Map();
34
+ return event => {
35
+ const cached = verified.get(event.id);
36
+ if (cached !== undefined) {
37
+ return cached === JSON.stringify([event.id, event.pubkey, event.created_at, event.kind, event.tags, event.content, event.sig]);
38
+ }
39
+ if (!verify(event))
40
+ return false;
41
+ verified.set(event.id, JSON.stringify([event.id, event.pubkey, event.created_at, event.kind, event.tags, event.content, event.sig]));
42
+ if (verified.size > limit)
43
+ verified.delete(verified.keys().next().value);
44
+ return true;
45
+ };
46
+ }
package/llms.txt ADDED
@@ -0,0 +1,100 @@
1
+ # @forgesworn/fold-kit
2
+
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
+
5
+ ESM-only, Node.js 22.13+. Two entry points: `@forgesworn/fold-kit` (everything) and `@forgesworn/fold-kit/lane` (relay lane classification only).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @forgesworn/fold-kit
11
+ ```
12
+
13
+ Not yet published to npm under this API. Until it is, pin an immutable Git commit:
14
+
15
+ ```json
16
+ {
17
+ "dependencies": {
18
+ "@forgesworn/fold-kit": "github:forgesworn/fold-kit#<commit>"
19
+ }
20
+ }
21
+ ```
22
+
23
+ ### Peer dependencies
24
+
25
+ `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
+
27
+ ## Exports
28
+
29
+ ### Identity, verification, hex
30
+
31
+ - `hexEquals(a, b)` → `boolean` - constant-shape hex comparison
32
+ - `normaliseHex(hex)` → `string` - lower-cases and validates hex
33
+ - `verifyEventUncached(event)` → `boolean`
34
+ - `boundedEventVerifier(limit?, verify?)` → `(event) => boolean` - an LRU-bounded verifier
35
+ - `localIdentity(participantSk)` → `ParticipantIdentity` - a local signer, plus the `ParticipantIdentity`/`UnsignedEvent` seam every codec below takes as a dependency
36
+
37
+ ### Wire kinds and types
38
+
39
+ - `KINDS` → `{ CREDENTIAL: 20460, CHAT: 1460, INVITATION_REQUEST: 20466, INVITATION_GRANT: 20467, INVITATION_RETIREMENT: 1461, GROUP_INVITATION: 1463, ROOM_REKEY: 1462, EPOCH_REQUEST: 20468, EPOCH_GRANT: 20469 }`
40
+ - `DeviceCredential`, `AccessTier` (`'open' | 'ken' | 'kith' | 'kin'`), `AgentRule`, `RoomPolicy`, `KindredProof` - link-layer wire types
41
+ - `RelayTransport` → `{ publish(event), subscribe(filters, onEvent, onEose?), close() }` - the transport seam every relay-facing codec takes; this kit declares its own interface rather than importing a relay pool implementation
42
+
43
+ ### Rooms, credentials, access
44
+
45
+ - `generateRoomSecret()` → `Uint8Array` (32 bytes)
46
+ - `deriveRoom(secret)` → `{ roomId, roomKey }`
47
+ - `encodeJoinUrl(base, secret, relays, policy?)` / `decodeJoinUrl(url)` - legacy v1 join URL
48
+ - `parseRoomPolicy(raw)` → `RoomPolicy | undefined`
49
+ - `createDeviceCredential(opts)` → `Promise<DeviceCredential>` - room-scope or person-scope (`scope: 'person'`)
50
+ - `verifyDeviceCredential(event, opts)` → `VerifyResult` - pass `acceptPerson: true` to accept a person-scope credential where a room credential is expected
51
+ - `PERSON_CREDENTIAL_MAX_SECONDS` - 30-day cap on a person-scope credential
52
+ - `issueKindredProof(opts)` → `KindredProof`
53
+ - `evaluateAccess(policy, participant, proof, now, roomId)` → `{ admitted, reason }`
54
+ - `sanitiseDisplayName(raw)` → `string | undefined`, `MAX_DISPLAY_NAME_LENGTH`
55
+ - `safeRelayUrls(raw)`, `safeIceUrls(raw)`, `assertNetworkHintBounds(relays, ice)`, `MAX_RELAY_HINTS`, `MAX_ICE_HINTS`, `MAX_NETWORK_HINT_LENGTH`
56
+
57
+ ### Links and invitations
58
+
59
+ - `parseRoomLink(url)` / `encodeRoomLink(base, link)` → `RoomLink` - the v1/v2/v3 link envelope
60
+ - `createRoomInvitation(persistent?)`, `roomInvitation(bearer, inviter, persistent?)`, `deriveInvitationId(invitation)`
61
+ - `encodeInvitationRequest(opts)` / `decodeInvitationRequest(event, opts)` - v2 live rendezvous request
62
+ - `encodeInvitationGrant(opts)` / `decodeRoomAdmissionGrant(event, opts)` - v2 grant, with a delegation chain
63
+ - `verifyInvitationDelegation(invitation, chain, now)`
64
+ - `encodeInvitationRetirement(opts)` / `decodeInvitationRetirementNotice(event, invitation)` - durable tombstone (kind 1461)
65
+ - `hostRoomInvitation(opts)` / `requestRoomAdmission(opts)` - the live desk, both sides
66
+ - `encodePersistentInvitation(opts)` / `decodePersistentInvitation(event, invitation)` - v3 stored group invitation (kind 1463)
67
+ - `requestPersistentRoomAdmission(opts)` - joins from stored admission alone, no live desk needed
68
+
69
+ ### Epochs (removal by rekey)
70
+
71
+ - `deriveEpoch({ epoch, secret })` → `{ epoch, id, key }`, `generateEpochSecret()`
72
+ - `encodeRekeyEvent(opts)` / `peekRekeyEvent(event, opts)` / `decodeRekeyEvent(event, opts)` - the durable rekey notice (kind 1462), sealed per remaining device, naming who was removed
73
+ - `encodeEpochRequest(opts)` / `decodeEpochRequest(event, opts)` - a device asking the authority for the current epoch
74
+ - `encodeEpochGrant(opts)` / `decodeEpochGrant(event, opts)` - the authority's answer (secret, epoch-0, or a refusal `'removed' | 'closed'`)
75
+ - `epochRequestAdmission(opts)`, `deriveEpochRequestKey(roomKey)`
76
+ - `hostRoomEpoch(opts)` / `requestRoomEpoch(opts)` / `EpochRefusedError` - the live request/grant desk, both sides
77
+ - `canonicalAdmins(admins)` / `signAdmins(opts)` / `verifyAdmins(opts)` - the authority's signed admin list
78
+ - `canonicalChannels(channels)` / `signChannels(opts)` / `verifyChannels(opts)` - the authority's signed channel list; `CHANNEL_NAME`, `RESERVED_CHANNELS`
79
+
80
+ ### Channel derivation
81
+
82
+ - `deriveChannel(roomId, roomKey, channel?)` → `{ id, key }` - a named channel's id/key, derived from the room key; `CHANNEL_ID_INFO`, `CHANNEL_KEY_INFO`, `MAX_CHANNEL_NAME_LENGTH`. The chat log and event codecs themselves stay in the consuming app.
83
+
84
+ ### Lane (`@forgesworn/fold-kit/lane`)
85
+
86
+ - `laneOfRelayUrl(url, circle?)` / `laneOfRelays(urls, circle?)` → `Lane | undefined` (`'public' | 'sheltered' | 'direct'`)
87
+ - `isLane(value)`, `weakestLane(lanes)`, `isDowngrade(requested, actual)`
88
+ - `LANES`, `LANE_MEANING`, `LANE_LABEL`, `LANE_GLYPH`
89
+
90
+ ## Design notes
91
+
92
+ - 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.
93
+ - No baked-in relay defaults, no storage, no DOM access. A caller supplies a `RelayTransport` implementation (a relay pool, or a test double).
94
+ - A room's authority - the party that may rekey and remove members - is the root inviter key pinned in the room's link, not a separate role system.
95
+
96
+ ## Docs
97
+
98
+ - README.md - full API overview, peer dependency rationale, how this kit relates to `covey-kit` and `roost-kit`
99
+ - EXTRACTION.md - the source commit, exactly which modules and functions moved, and the two splits (`access.ts`, `chat.ts`) performed on the way
100
+ - AGENTS.md - build/test commands and repository conventions for an agent working in this repo
package/package.json CHANGED
@@ -1,19 +1,89 @@
1
1
  {
2
2
  "name": "@forgesworn/fold-kit",
3
- "version": "0.0.0-seed.0",
3
+ "version": "0.1.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
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./lane": {
14
+ "types": "./dist/lane.d.ts",
15
+ "default": "./dist/lane.js"
16
+ },
17
+ "./package.json": "./package.json"
18
+ },
6
19
  "files": [
20
+ "dist",
21
+ "vectors/kithmoot-vectors.json",
22
+ "vectors/circle-vectors.json",
7
23
  "LICENSE",
8
- "README.md"
24
+ "README.md",
25
+ "llms.txt"
9
26
  ],
10
- "repository": {
11
- "type": "git",
12
- "url": "git+https://github.com/forgesworn/fold-kit.git"
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.json",
29
+ "test": "vitest run",
30
+ "test:watch": "vitest",
31
+ "typecheck": "tsc -p tsconfig.typecheck.json",
32
+ "diff-source": "node scripts/diff-source.mjs",
33
+ "bundle-check": "node scripts/bundle-check.mjs",
34
+ "tarball-smoke": "node scripts/tarball-smoke.mjs",
35
+ "vectors": "vitest run vectors/",
36
+ "check": "npm run typecheck && npm test && npm run diff-source",
37
+ "prepare": "npm run build",
38
+ "prepublishOnly": "npm run check && npm run bundle-check && npm run tarball-smoke"
13
39
  },
40
+ "keywords": [
41
+ "nostr",
42
+ "e2ee",
43
+ "circle",
44
+ "group-chat",
45
+ "epoch-key",
46
+ "invite-link",
47
+ "device-credential"
48
+ ],
14
49
  "author": "forgesworn",
15
50
  "license": "MIT",
51
+ "sideEffects": false,
52
+ "engines": {
53
+ "node": ">=22.13"
54
+ },
55
+ "peerDependencies": {
56
+ "nostr-tools": ">=2.24.2 <3",
57
+ "@noble/hashes": "^1.8.0",
58
+ "@noble/curves": "^2.0.1"
59
+ },
60
+ "dependencies": {
61
+ "@scure/base": "^2.0.0"
62
+ },
63
+ "devDependencies": {
64
+ "@noble/curves": "2.0.1",
65
+ "@noble/hashes": "^1.8.0",
66
+ "@types/node": "^22",
67
+ "esbuild": "^0.25.0",
68
+ "nostr-tools": "2.25.0",
69
+ "signet-login": "0.17.1",
70
+ "typescript": "^5.6.0",
71
+ "vitest": "^4.1.11"
72
+ },
16
73
  "publishConfig": {
17
- "access": "public"
74
+ "access": "public",
75
+ "provenance": true
76
+ },
77
+ "funding": {
78
+ "type": "lightning",
79
+ "url": "lightning:profusemeat89@walletofsatoshi.com"
80
+ },
81
+ "homepage": "https://github.com/forgesworn/fold-kit#readme",
82
+ "bugs": {
83
+ "url": "https://github.com/forgesworn/fold-kit/issues"
84
+ },
85
+ "repository": {
86
+ "type": "git",
87
+ "url": "git+https://github.com/forgesworn/fold-kit.git"
18
88
  }
19
89
  }