nostr-double-ratchet 0.0.37 → 0.0.48

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/dist/types.d.ts CHANGED
@@ -55,6 +55,14 @@ export type Unsubscribe = () => void;
55
55
  export type NostrSubscribe = (_filter: Filter, _onEvent: (_e: VerifiedEvent) => void) => Unsubscribe;
56
56
  export type EncryptFunction = (_plaintext: string, _pubkey: string) => Promise<string>;
57
57
  export type DecryptFunction = (_ciphertext: string, _pubkey: string) => Promise<string>;
58
+ /**
59
+ * Identity key for cryptographic operations.
60
+ * Either a raw private key (Uint8Array) or encrypt/decrypt functions for extension login (NIP-07).
61
+ */
62
+ export type IdentityKey = Uint8Array | {
63
+ encrypt: EncryptFunction;
64
+ decrypt: DecryptFunction;
65
+ };
58
66
  export type NostrPublish = (_event: UnsignedEvent) => Promise<VerifiedEvent>;
59
67
  export type Rumor = UnsignedEvent & {
60
68
  id: string;
@@ -74,8 +82,8 @@ export declare const MESSAGE_EVENT_KIND = 1060;
74
82
  */
75
83
  export declare const INVITE_EVENT_KIND = 30078;
76
84
  export declare const INVITE_RESPONSE_KIND = 1059;
85
+ export declare const APP_KEYS_EVENT_KIND = 30078;
77
86
  export declare const CHAT_MESSAGE_KIND = 14;
78
- export declare const MAX_SKIP = 100;
79
87
  export type NostrEvent = {
80
88
  id: string;
81
89
  pubkey: string;
@@ -85,4 +93,27 @@ export type NostrEvent = {
85
93
  content: string;
86
94
  sig: string;
87
95
  };
96
+ /**
97
+ * Payload for reaction messages sent through NDR.
98
+ * Reactions are regular messages with a JSON payload indicating they're a reaction.
99
+ */
100
+ export interface ReactionPayload {
101
+ type: 'reaction';
102
+ /** ID of the message being reacted to */
103
+ messageId: string;
104
+ /** Emoji or reaction content */
105
+ emoji: string;
106
+ }
107
+ /**
108
+ * Kind constant for reaction inner events
109
+ */
110
+ export declare const REACTION_KIND = 7;
111
+ /**
112
+ * Kind constant for read/delivery receipt inner events
113
+ */
114
+ export declare const RECEIPT_KIND = 15;
115
+ /**
116
+ * Kind constant for typing indicator inner events
117
+ */
118
+ export declare const TYPING_KIND = 25;
88
119
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEnE,MAAM,MAAM,MAAM,GAAG;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,mBAAmB,EAAE,MAAM,CAAC;IAC5B,aAAa,EAAE,MAAM,CAAC;CACvB,CAAA;AAED;;GAEG;AACH,MAAM,MAAM,OAAO,GAAG;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,UAAU,CAAC;CACxB,CAAA;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,iEAAiE;IACjE,OAAO,EAAE,UAAU,CAAC;IAEpB,iDAAiD;IACjD,0BAA0B,CAAC,EAAE,MAAM,CAAC;IAEpC,8CAA8C;IAC9C,uBAAuB,EAAE,MAAM,CAAC;IAEhC,sDAAsD;IACtD,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAE7B,kGAAkG;IAClG,eAAe,EAAE,OAAO,CAAC;IAEzB,4DAA4D;IAC5D,iBAAiB,CAAC,EAAE,UAAU,CAAC;IAE/B,4DAA4D;IAC5D,eAAe,CAAC,EAAE,UAAU,CAAC;IAE7B,uDAAuD;IACvD,yBAAyB,EAAE,MAAM,CAAC;IAElC,6DAA6D;IAC7D,2BAA2B,EAAE,MAAM,CAAC;IAEpC,wDAAwD;IACxD,gCAAgC,EAAE,MAAM,CAAC;IAEzC,wEAAwE;IACxE,WAAW,EAAE;QACX,CAAC,MAAM,EAAE,MAAM,GAAG;YAChB,UAAU,EAAE,UAAU,EAAE,CAAC;YACzB,WAAW,EAAE;gBAAC,CAAC,QAAQ,EAAE,MAAM,GAAG,UAAU,CAAA;aAAC,CAAA;SAC9C,CAAC;KACH,CAAC;CACH;AAED;;GAEG;AACH,MAAM,MAAM,WAAW,GAAG,MAAM,IAAI,CAAC;AAErC;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,EAAE,EAAE,aAAa,KAAK,IAAI,KAAK,WAAW,CAAC;AACrG,MAAM,MAAM,eAAe,GAAG,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AACvF,MAAM,MAAM,eAAe,GAAG,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AACxF,MAAM,MAAM,YAAY,GAAG,CAAC,MAAM,EAAE,aAAa,KAAK,OAAO,CAAC,aAAa,CAAC,CAAC;AAE7E,MAAM,MAAM,KAAK,GAAG,aAAa,GAAG;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAAA;AAElD;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,aAAa,KAAK,IAAI,CAAC;AAEhF;;GAEG;AACH,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC;;GAEG;AACH,eAAO,MAAM,iBAAiB,QAAQ,CAAC;AAEvC,eAAO,MAAM,oBAAoB,OAAO,CAAC;AAEzC,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAEpC,eAAO,MAAM,QAAQ,MAAM,CAAC;AAE5B,MAAM,MAAM,UAAU,GAAG;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;CACb,CAAA"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/utils.d.ts CHANGED
@@ -1,10 +1,28 @@
1
- import { Rumor, SessionState } from "./types";
1
+ import { Rumor, SessionState, ReactionPayload } from "./types";
2
2
  import { Session } from "./Session.ts";
3
3
  export declare function serializeSessionState(state: SessionState): string;
4
4
  export declare function deserializeSessionState(data: string): SessionState;
5
5
  export declare function deepCopyState(s: SessionState): SessionState;
6
6
  export declare function createEventStream(session: Session): AsyncGenerator<Rumor, void, unknown>;
7
7
  export declare function kdf(input1: Uint8Array, input2?: Uint8Array, numOutputs?: number): Uint8Array[];
8
- export declare function skippedMessageIndexKey(_nostrSender: string, _number: number): string;
9
8
  export declare function getMillisecondTimestamp(event: Rumor): number;
9
+ /**
10
+ * Check if a message content is a reaction payload.
11
+ * @param content The message content to check
12
+ * @returns The parsed ReactionPayload if valid, null otherwise
13
+ */
14
+ export declare function parseReaction(content: string): ReactionPayload | null;
15
+ /**
16
+ * Check if a message content is a reaction.
17
+ * @param content The message content to check
18
+ * @returns true if the content is a reaction payload
19
+ */
20
+ export declare function isReaction(content: string): boolean;
21
+ /**
22
+ * Create a reaction payload JSON string.
23
+ * @param messageId The ID of the message being reacted to
24
+ * @param emoji The emoji or reaction content
25
+ * @returns JSON string of the reaction payload
26
+ */
27
+ export declare function createReactionPayload(messageId: string, emoji: string): string;
10
28
  //# sourceMappingURL=utils.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAMvC,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,CAkCjE;AAED,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAuElE;AAED,wBAAgB,aAAa,CAAC,CAAC,EAAE,YAAY,GAAG,YAAY,CAgC3D;AAGD,wBAAuB,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,cAAc,CAAC,KAAK,EAAE,IAAI,EAAE,OAAO,CAAC,CA0B/F;AAED,wBAAgB,GAAG,CAAC,MAAM,EAAE,UAAU,EAAE,MAAM,GAAE,UAA+B,EAAE,UAAU,GAAE,MAAU,GAAG,UAAU,EAAE,CAQrH;AAED,wBAAgB,sBAAsB,CAAC,YAAY,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAEpF;AAED,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,KAAK,UAMnD"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAC/D,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAMvC,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,CAkCjE;AAED,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAuElE;AAED,wBAAgB,aAAa,CAAC,CAAC,EAAE,YAAY,GAAG,YAAY,CAgC3D;AAGD,wBAAuB,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,cAAc,CAAC,KAAK,EAAE,IAAI,EAAE,OAAO,CAAC,CA0B/F;AAED,wBAAgB,GAAG,CAAC,MAAM,EAAE,UAAU,EAAE,MAAM,GAAE,UAA+B,EAAE,UAAU,GAAE,MAAU,GAAG,UAAU,EAAE,CAQrH;AAED,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,KAAK,UAMnD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI,CAUrE;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAEnD;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAO9E"}
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "nostr-double-ratchet",
3
- "version": "0.0.37",
3
+ "version": "0.0.48",
4
4
  "type": "module",
5
- "packageManager": "yarn@1.22.22",
5
+ "packageManager": "pnpm@9.15.0",
6
6
  "description": "Nostr double ratchet library",
7
7
  "main": "dist/nostr-double-ratchet.umd.js",
8
8
  "module": "dist/nostr-double-ratchet.es.js",
@@ -12,29 +12,19 @@
12
12
  "test:once": "vitest run --exclude '**/*.integration.test.ts'",
13
13
  "test:integration": "DEBUG='ndk:*' vitest '**/*.integration.test.ts'",
14
14
  "build": "vite build && tsc",
15
- "examples": "cd examples && vite build && tsc",
16
- "examples-dev": "cd examples && vite && tsc",
17
- "docs": "typedoc --out docs src/index.ts",
18
15
  "lint": "eslint src --ext .ts --fix"
19
16
  },
20
- "repository": {
21
- "type": "git",
22
- "url": "git+https://github.com/mmalmi/nostr-double-ratchet.git"
23
- },
17
+ "repository": "https://files.iris.to/#/npub1xndmdgymsf4a34rzr7346vp8qcptxf75pjqweh8naa8rklgxpfqqmfjtce/nostr-double-ratchet",
24
18
  "author": "Martti Malmi",
25
19
  "license": "MIT",
26
- "bugs": {
27
- "url": "https://github.com/mmalmi/nostr-double-ratchet/issues"
28
- },
29
- "homepage": "https://github.com/mmalmi/nostr-double-ratchet",
30
- "documentation": "https://mmalmi.github.io/nostr-double-ratchet/",
20
+ "homepage": "https://files.iris.to/#/npub1xndmdgymsf4a34rzr7346vp8qcptxf75pjqweh8naa8rklgxpfqqmfjtce/nostr-double-ratchet",
31
21
  "files": [
32
22
  "src",
33
23
  "dist"
34
24
  ],
35
25
  "devDependencies": {
26
+ "@noble/hashes": "^1.3.1",
36
27
  "@nostr-dev-kit/ndk": "2.14.23",
37
- "@types/lodash": "^4.17.17",
38
28
  "@types/node": "^22.15.21",
39
29
  "@typescript-eslint/eslint-plugin": "^8.33.1",
40
30
  "@typescript-eslint/parser": "^8.33.1",
@@ -42,12 +32,8 @@
42
32
  "eslint-config-prettier": "^10.1.5",
43
33
  "eslint-plugin-prettier": "^5.4.0",
44
34
  "eslint-plugin-simple-import-sort": "^12.1.1",
45
- "lodash": "^4.17.21",
46
- "react-blurhash": "^0.3.0",
47
35
  "tsx": "^4.19.4",
48
- "typedoc": "^0.28.4",
49
36
  "typescript": "^5.8.3",
50
- "typescript-lru-cache": "^2.0.0",
51
37
  "vite": "^6.3.5",
52
38
  "vitest": "^3.1.4",
53
39
  "ws": "^8.18.2"
package/src/AppKeys.ts ADDED
@@ -0,0 +1,210 @@
1
+ import { VerifiedEvent, UnsignedEvent, verifyEvent } from "nostr-tools"
2
+ import { APP_KEYS_EVENT_KIND, NostrSubscribe, Unsubscribe } from "./types"
3
+
4
+ const now = () => Math.round(Date.now() / 1000)
5
+
6
+ // Simplified tag format: ["device", identityPubkey, createdAt]
7
+ type DeviceTag = [
8
+ type: "device",
9
+ identityPubkey: string,
10
+ createdAt: string,
11
+ ]
12
+
13
+ const isDeviceTag = (tag: string[]): tag is DeviceTag =>
14
+ tag.length >= 3 &&
15
+ tag[0] === "device" &&
16
+ typeof tag[1] === "string" &&
17
+ typeof tag[2] === "string"
18
+
19
+ /**
20
+ * Device identity entry - contains only identity information.
21
+ * identityPubkey serves as the device identifier.
22
+ * Invite crypto material (ephemeral keys, shared secret) is in separate Invite events.
23
+ */
24
+ export interface DeviceEntry {
25
+ /** Identity public key - also serves as device identifier */
26
+ identityPubkey: string
27
+ createdAt: number
28
+ }
29
+
30
+ /**
31
+ * Manages a consolidated list of device invites (kind 30078, d-tag "double-ratchet/app-keys").
32
+ * Single atomic event containing all device invites for a user.
33
+ * Uses union merge strategy for conflict resolution.
34
+ *
35
+ * Note: ownerPublicKey is not stored - it's passed to getEvent() when publishing,
36
+ * and NDK's signer sets the correct pubkey during signing anyway.
37
+ */
38
+ export class AppKeys {
39
+ private devices: Map<string, DeviceEntry> = new Map()
40
+
41
+ constructor(devices: DeviceEntry[] = []) {
42
+ devices.forEach((device) => this.devices.set(device.identityPubkey, device))
43
+ }
44
+
45
+ /**
46
+ * Creates a new device identity entry.
47
+ * Note: This only creates the identity entry. The device must separately
48
+ * create and publish its own Invite event with ephemeral keys.
49
+ */
50
+ createDeviceEntry(identityPubkey: string): DeviceEntry {
51
+ return {
52
+ identityPubkey,
53
+ createdAt: now(),
54
+ }
55
+ }
56
+
57
+ addDevice(device: DeviceEntry): void {
58
+ if (!this.devices.has(device.identityPubkey)) {
59
+ this.devices.set(device.identityPubkey, device)
60
+ }
61
+ }
62
+
63
+ removeDevice(identityPubkey: string): void {
64
+ this.devices.delete(identityPubkey)
65
+ }
66
+
67
+ getDevice(identityPubkey: string): DeviceEntry | undefined {
68
+ return this.devices.get(identityPubkey)
69
+ }
70
+
71
+ getAllDevices(): DeviceEntry[] {
72
+ return Array.from(this.devices.values())
73
+ }
74
+
75
+ getEvent(): UnsignedEvent {
76
+ const deviceTags = this.getAllDevices().map((device) => [
77
+ "device",
78
+ device.identityPubkey,
79
+ String(device.createdAt),
80
+ ])
81
+
82
+ return {
83
+ kind: APP_KEYS_EVENT_KIND,
84
+ pubkey: "", // Signer will set this
85
+ content: "",
86
+ created_at: now(),
87
+ tags: [
88
+ ["d", "double-ratchet/app-keys"],
89
+ ["version", "1"],
90
+ ...deviceTags,
91
+ ],
92
+ }
93
+ }
94
+
95
+ static fromEvent(event: VerifiedEvent): AppKeys {
96
+ if (!event.sig) {
97
+ throw new Error("Event is not signed")
98
+ }
99
+ if (!verifyEvent(event)) {
100
+ throw new Error("Event signature is invalid")
101
+ }
102
+
103
+ // Simplified tag format: ["device", identityPubkey, createdAt]
104
+ // Note: "removed" tags are ignored for backwards compatibility with old events
105
+ const devices = event.tags
106
+ .filter(isDeviceTag)
107
+ .map(([, identityPubkey, createdAt]) => ({
108
+ identityPubkey,
109
+ createdAt: parseInt(createdAt, 10) || event.created_at,
110
+ }))
111
+
112
+ return new AppKeys(devices)
113
+ }
114
+
115
+ serialize(): string {
116
+ return JSON.stringify({
117
+ devices: this.getAllDevices(),
118
+ })
119
+ }
120
+
121
+ static deserialize(json: string): AppKeys {
122
+ const data = JSON.parse(json) as {
123
+ devices: DeviceEntry[]
124
+ }
125
+ return new AppKeys(data.devices)
126
+ }
127
+
128
+ merge(other: AppKeys): AppKeys {
129
+ // Merge devices, preferring the one with earlier createdAt for same identityPubkey
130
+ const mergedDevices = [...this.devices.values(), ...other.devices.values()]
131
+ .reduce((map, device) => {
132
+ const existing = map.get(device.identityPubkey)
133
+ if (!existing || device.createdAt < existing.createdAt) {
134
+ map.set(device.identityPubkey, device)
135
+ }
136
+ return map
137
+ }, new Map<string, DeviceEntry>())
138
+
139
+ return new AppKeys(Array.from(mergedDevices.values()))
140
+ }
141
+
142
+ /**
143
+ * Subscribe to AppKeys events from a user.
144
+ * Similar to Invite.fromUser pattern.
145
+ */
146
+ static fromUser(
147
+ user: string,
148
+ subscribe: NostrSubscribe,
149
+ onAppKeysList: (appKeys: AppKeys) => void
150
+ ): Unsubscribe {
151
+ return subscribe(
152
+ {
153
+ kinds: [APP_KEYS_EVENT_KIND],
154
+ authors: [user],
155
+ "#d": ["double-ratchet/app-keys"],
156
+ },
157
+ (event) => {
158
+ if (event.pubkey !== user) return
159
+ try {
160
+ const appKeys = AppKeys.fromEvent(event)
161
+ onAppKeysList(appKeys)
162
+ } catch {
163
+ // Invalid event
164
+ }
165
+ }
166
+ )
167
+ }
168
+
169
+ /**
170
+ * Wait for AppKeys from a user with timeout.
171
+ * Returns the most recent AppKeys received within the timeout, or null.
172
+ * Note: Uses the most recent event by created_at, not merging, since
173
+ * device revocation is determined by absence from the list.
174
+ */
175
+ static waitFor(
176
+ user: string,
177
+ subscribe: NostrSubscribe,
178
+ timeoutMs = 500
179
+ ): Promise<AppKeys | null> {
180
+ return new Promise((resolve) => {
181
+ let latest: { list: AppKeys; createdAt: number } | null = null
182
+
183
+ setTimeout(() => {
184
+ unsubscribe()
185
+ resolve(latest?.list ?? null)
186
+ }, timeoutMs)
187
+
188
+ const unsubscribe = subscribe(
189
+ {
190
+ kinds: [APP_KEYS_EVENT_KIND],
191
+ authors: [user],
192
+ "#d": ["double-ratchet/app-keys"],
193
+ },
194
+ (event) => {
195
+ if (event.pubkey !== user) return
196
+ try {
197
+ const list = AppKeys.fromEvent(event)
198
+ // Use >= to prefer later-delivered events when timestamps are equal
199
+ // This handles replaceable events created within the same second
200
+ if (!latest || event.created_at >= latest.createdAt) {
201
+ latest = { list, createdAt: event.created_at }
202
+ }
203
+ } catch {
204
+ // Invalid event
205
+ }
206
+ }
207
+ )
208
+ })
209
+ }
210
+ }