@qntx/nostr 0.3.1 → 0.4.1
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/nips/nip27.d.mts +28 -15
- package/dist/nips/nip27.mjs +192 -159
- package/dist/nips/nip27.mjs.map +1 -1
- package/dist/nips/nip57.d.mts +6 -0
- package/dist/nips/nip57.mjs +33 -3
- package/dist/nips/nip57.mjs.map +1 -1
- package/package.json +1 -1
package/dist/nips/nip27.d.mts
CHANGED
|
@@ -6,36 +6,49 @@ export type ContentBlock = {
|
|
|
6
6
|
text: string;
|
|
7
7
|
} | {
|
|
8
8
|
type: "reference";
|
|
9
|
-
pointer: ProfilePointer |
|
|
9
|
+
pointer: ProfilePointer | EventPointer | AddressPointer;
|
|
10
|
+
/**
|
|
11
|
+
* `true` when the reference had no `nostr:` prefix (see
|
|
12
|
+
* {@link ParseContentOptions.legacyBech32}).
|
|
13
|
+
*/
|
|
14
|
+
bare: boolean;
|
|
10
15
|
} | {
|
|
11
16
|
type: "url";
|
|
12
17
|
url: string;
|
|
13
18
|
} | {
|
|
14
|
-
type: "
|
|
15
|
-
url: string;
|
|
16
|
-
} | {
|
|
17
|
-
type: "image";
|
|
19
|
+
type: "image" | "video" | "audio";
|
|
18
20
|
url: string;
|
|
19
21
|
} | {
|
|
20
|
-
type: "
|
|
22
|
+
type: "relay";
|
|
21
23
|
url: string;
|
|
22
24
|
} | {
|
|
23
|
-
type: "
|
|
24
|
-
|
|
25
|
+
type: "hashtag";
|
|
26
|
+
value: string;
|
|
25
27
|
} | {
|
|
26
28
|
type: "emoji";
|
|
27
29
|
shortcode: string;
|
|
28
30
|
url: string;
|
|
29
31
|
} | {
|
|
30
|
-
type: "
|
|
31
|
-
|
|
32
|
+
type: "invoice";
|
|
33
|
+
bolt11: string;
|
|
34
|
+
};
|
|
35
|
+
export type ParseContentOptions = {
|
|
36
|
+
/**
|
|
37
|
+
* Recognize bare `npub`, `nprofile`, `note`, `nevent`, `naddr` without the `nostr:` prefix.
|
|
38
|
+
* Default `false`.
|
|
39
|
+
*/
|
|
40
|
+
legacyBech32?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* URL → MIME type (the `m` field of NIP-92 `imeta` tags); takes precedence over extension
|
|
43
|
+
* classification.
|
|
44
|
+
*/
|
|
45
|
+
imeta?: ReadonlyMap<string, string>;
|
|
32
46
|
};
|
|
33
47
|
/**
|
|
34
|
-
* Parse note content (or a full event, to pick up emoji tags) into blocks.
|
|
35
|
-
*
|
|
48
|
+
* Parse note content (or a full event, to pick up `emoji` tags) into ordered blocks. One
|
|
49
|
+
* left-to-right scan; at every position the rules fire in the alternation order above. Adjacent
|
|
50
|
+
* text merges and no empty text blocks are emitted.
|
|
36
51
|
*/
|
|
37
|
-
export declare function parseContent(content: string | Pick<Event, "content" | "tags"
|
|
38
|
-
/** Collect all content blocks into an array. */
|
|
39
|
-
export declare function parseContentBlocks(content: string | Pick<Event, "content" | "tags">): ContentBlock[];
|
|
52
|
+
export declare function parseContent(content: string | Pick<Event, "content" | "tags">, opts?: ParseContentOptions): ContentBlock[];
|
|
40
53
|
//#endregion
|
|
41
54
|
//# sourceMappingURL=nip27.d.mts.map
|
package/dist/nips/nip27.mjs
CHANGED
|
@@ -1,176 +1,209 @@
|
|
|
1
1
|
import { decode } from "./nip19.mjs";
|
|
2
2
|
//#region src/nips/nip27.ts
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
const
|
|
3
|
+
const BECH32_CHARS = "02-9ac-hj-np-z";
|
|
4
|
+
const NIP19_PREFIX = "(?:npub|nprofile|note|nevent|naddr|nsec)1";
|
|
5
|
+
const NIP19_BARE_PREFIX = "(?:npub|nprofile|note|nevent|naddr)1";
|
|
6
|
+
const URL_RUN = `[^\\s<>"\\u3000-\\u303f\\uff00-\\uffef]+`;
|
|
7
|
+
const SCAN_BODY = [
|
|
8
|
+
`(?<nostr>nostr:${NIP19_PREFIX}[${BECH32_CHARS}]+)`,
|
|
9
|
+
`(?<url>https?://${URL_RUN})`,
|
|
10
|
+
`(?<relay>wss?://${URL_RUN})`,
|
|
11
|
+
`(?<invoice>(?:lightning:)?ln(?:bcrt|tbs|bc|tb)(?:\\d+[munp]?)?1[${BECH32_CHARS}]+)`,
|
|
12
|
+
`(?<hashtag>#[\\p{L}\\p{M}\\p{N}_]{1,42})`,
|
|
13
|
+
`(?<emoji>:[A-Za-z0-9_]+:)`
|
|
14
|
+
].join("|");
|
|
15
|
+
const SCAN = new RegExp(SCAN_BODY, "giu");
|
|
16
|
+
const SCAN_LEGACY = new RegExp(`${SCAN_BODY}|(?<bare>${NIP19_BARE_PREFIX}[${BECH32_CHARS}]+)`, "giu");
|
|
17
|
+
const TAG_CHAR = /[\p{L}\p{M}\p{N}_]/u;
|
|
18
|
+
const WORD_CHAR = /[\p{L}\p{N}]/u;
|
|
19
|
+
const TRAILING_URL_CHARS = /* @__PURE__ */ new Set([
|
|
20
|
+
".",
|
|
21
|
+
",",
|
|
22
|
+
";",
|
|
23
|
+
":",
|
|
24
|
+
"!",
|
|
25
|
+
"?",
|
|
26
|
+
"'"
|
|
27
|
+
]);
|
|
28
|
+
const IMAGE_EXT = /* @__PURE__ */ new Set([
|
|
29
|
+
"jpg",
|
|
30
|
+
"jpeg",
|
|
31
|
+
"png",
|
|
32
|
+
"gif",
|
|
33
|
+
"webp",
|
|
34
|
+
"avif",
|
|
35
|
+
"svg"
|
|
36
|
+
]);
|
|
37
|
+
const VIDEO_EXT = /* @__PURE__ */ new Set([
|
|
38
|
+
"mp4",
|
|
39
|
+
"webm",
|
|
40
|
+
"mov",
|
|
41
|
+
"m4v",
|
|
42
|
+
"m3u8"
|
|
43
|
+
]);
|
|
44
|
+
const AUDIO_EXT = /* @__PURE__ */ new Set([
|
|
45
|
+
"mp3",
|
|
46
|
+
"m4a",
|
|
47
|
+
"ogg",
|
|
48
|
+
"oga",
|
|
49
|
+
"opus",
|
|
50
|
+
"wav",
|
|
51
|
+
"flac",
|
|
52
|
+
"aac"
|
|
53
|
+
]);
|
|
54
|
+
function isChar(cp, re) {
|
|
55
|
+
return cp !== void 0 && re.test(String.fromCodePoint(cp));
|
|
56
|
+
}
|
|
57
|
+
/** Drop trailing sentence punctuation and closers that have no opener inside the URL. */
|
|
58
|
+
function trimUrlTail(raw) {
|
|
59
|
+
let url = raw;
|
|
60
|
+
for (;;) {
|
|
61
|
+
const last = url.at(-1);
|
|
62
|
+
if (last !== void 0 && TRAILING_URL_CHARS.has(last)) {
|
|
63
|
+
url = url.slice(0, -1);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (last === ")" || last === "]") {
|
|
67
|
+
const open = last === ")" ? "(" : "[";
|
|
68
|
+
let opens = 0;
|
|
69
|
+
let closes = 0;
|
|
70
|
+
for (const c of url) if (c === open) opens++;
|
|
71
|
+
else if (c === last) closes++;
|
|
72
|
+
if (closes > opens) {
|
|
73
|
+
url = url.slice(0, -1);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return url;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function mimeMedia(mime) {
|
|
81
|
+
if (mime === void 0) return;
|
|
82
|
+
const slash = mime.indexOf("/");
|
|
83
|
+
const base = slash === -1 ? mime : mime.slice(0, slash);
|
|
84
|
+
return base === "image" || base === "video" || base === "audio" ? base : void 0;
|
|
85
|
+
}
|
|
86
|
+
function extMedia(pathname) {
|
|
87
|
+
const dot = pathname.lastIndexOf(".");
|
|
88
|
+
if (dot === -1) return;
|
|
89
|
+
const ext = pathname.slice(dot + 1).toLowerCase();
|
|
90
|
+
if (IMAGE_EXT.has(ext)) return "image";
|
|
91
|
+
if (VIDEO_EXT.has(ext)) return "video";
|
|
92
|
+
return AUDIO_EXT.has(ext) ? "audio" : void 0;
|
|
93
|
+
}
|
|
94
|
+
function urlBlock(raw, relay, imeta) {
|
|
95
|
+
const url = trimUrlTail(raw);
|
|
96
|
+
let parsed;
|
|
97
|
+
try {
|
|
98
|
+
parsed = new URL(url);
|
|
99
|
+
} catch {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!parsed.hostname.includes(".")) return;
|
|
103
|
+
if (relay) return {
|
|
104
|
+
type: "relay",
|
|
105
|
+
url
|
|
106
|
+
};
|
|
107
|
+
const media = mimeMedia(imeta?.get(url)) ?? extMedia(parsed.pathname);
|
|
108
|
+
return media === void 0 ? {
|
|
109
|
+
type: "url",
|
|
110
|
+
url
|
|
111
|
+
} : {
|
|
112
|
+
type: media,
|
|
113
|
+
url
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function referenceFor(code, bare) {
|
|
117
|
+
let pointer;
|
|
118
|
+
try {
|
|
119
|
+
const decoded = decode(code);
|
|
120
|
+
switch (decoded.type) {
|
|
121
|
+
case "npub":
|
|
122
|
+
pointer = { pubkey: decoded.data };
|
|
123
|
+
break;
|
|
124
|
+
case "note":
|
|
125
|
+
pointer = { id: decoded.data };
|
|
126
|
+
break;
|
|
127
|
+
case "nprofile":
|
|
128
|
+
case "nevent":
|
|
129
|
+
case "naddr":
|
|
130
|
+
pointer = decoded.data;
|
|
131
|
+
break;
|
|
132
|
+
case "nsec": return;
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
type: "reference",
|
|
139
|
+
pointer,
|
|
140
|
+
bare
|
|
141
|
+
};
|
|
142
|
+
}
|
|
6
143
|
/**
|
|
7
|
-
* Parse note content (or a full event, to pick up emoji tags) into blocks.
|
|
8
|
-
*
|
|
144
|
+
* Parse note content (or a full event, to pick up `emoji` tags) into ordered blocks. One
|
|
145
|
+
* left-to-right scan; at every position the rules fire in the alternation order above. Adjacent
|
|
146
|
+
* text merges and no empty text blocks are emitted.
|
|
9
147
|
*/
|
|
10
|
-
function
|
|
11
|
-
const emojis =
|
|
148
|
+
function parseContent(content, opts) {
|
|
149
|
+
const emojis = /* @__PURE__ */ new Map();
|
|
12
150
|
let text;
|
|
13
151
|
if (typeof content === "string") text = content;
|
|
14
152
|
else {
|
|
15
|
-
for (const tag of content.tags)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
153
|
+
for (const tag of content.tags) if (tag[0] === "emoji" && tag[1] !== void 0 && tag[1] !== "" && tag[2] !== void 0 && tag[2] !== "") emojis.set(tag[1], tag[2]);
|
|
154
|
+
text = content.content;
|
|
155
|
+
}
|
|
156
|
+
const scan = opts?.legacyBech32 === true ? SCAN_LEGACY : SCAN;
|
|
157
|
+
scan.lastIndex = 0;
|
|
158
|
+
const blocks = [];
|
|
159
|
+
let textStart = 0;
|
|
160
|
+
const flushText = (end) => {
|
|
161
|
+
if (end > textStart) blocks.push({
|
|
162
|
+
type: "text",
|
|
163
|
+
text: text.slice(textStart, end)
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
for (let m = scan.exec(text); m !== null; m = scan.exec(text)) {
|
|
167
|
+
const start = m.index;
|
|
168
|
+
const [token] = m;
|
|
169
|
+
const end = start + token.length;
|
|
170
|
+
const g = m.groups;
|
|
171
|
+
let block;
|
|
172
|
+
if (g?.["nostr"] !== void 0) block = referenceFor(token.slice(6), false);
|
|
173
|
+
else if (g?.["url"] !== void 0) block = urlBlock(token, false, opts?.imeta);
|
|
174
|
+
else if (g?.["relay"] !== void 0) block = urlBlock(token, true, void 0);
|
|
175
|
+
else if (g?.["invoice"] !== void 0) {
|
|
176
|
+
if (!isChar(start === 0 ? void 0 : text.codePointAt(start - 1), WORD_CHAR) && !isChar(text.codePointAt(end), WORD_CHAR)) block = {
|
|
177
|
+
type: "invoice",
|
|
178
|
+
bolt11: (token.toLowerCase().startsWith("lightning:") ? token.slice(10) : token).toLowerCase()
|
|
179
|
+
};
|
|
180
|
+
} else if (g?.["hashtag"] !== void 0) {
|
|
181
|
+
if (!isChar(start === 0 ? void 0 : text.codePointAt(start - 1), TAG_CHAR)) block = {
|
|
182
|
+
type: "hashtag",
|
|
183
|
+
value: token.slice(1)
|
|
184
|
+
};
|
|
185
|
+
} else if (g?.["emoji"] !== void 0) {
|
|
186
|
+
const url = emojis.get(token.slice(1, -1));
|
|
187
|
+
if (url !== void 0) block = {
|
|
19
188
|
type: "emoji",
|
|
20
|
-
shortcode,
|
|
189
|
+
shortcode: token.slice(1, -1),
|
|
21
190
|
url
|
|
22
|
-
}
|
|
191
|
+
};
|
|
192
|
+
} else if (g?.["bare"] !== void 0) {
|
|
193
|
+
if (!isChar(start === 0 ? void 0 : text.codePointAt(start - 1), WORD_CHAR) && !isChar(text.codePointAt(end), WORD_CHAR)) block = referenceFor(token, true);
|
|
23
194
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const max = text.length;
|
|
27
|
-
let prevIndex = 0;
|
|
28
|
-
let index = 0;
|
|
29
|
-
mainloop: while (index < max) {
|
|
30
|
-
const u = text.indexOf(":", index);
|
|
31
|
-
const h = text.indexOf("#", index);
|
|
32
|
-
if (u === -1 && h === -1) break;
|
|
33
|
-
if (u === -1 || h !== -1 && h < u) {
|
|
34
|
-
if (h === 0 || h > 0 && noCharacter.exec(text.at(h - 1) ?? "") !== null) {
|
|
35
|
-
const m = noCharacter.exec(text.slice(h + 1, h + MAX_HASHTAG_LENGTH));
|
|
36
|
-
const end = m ? h + 1 + (m.index ?? 0) : max;
|
|
37
|
-
if (prevIndex !== h) yield {
|
|
38
|
-
type: "text",
|
|
39
|
-
text: text.slice(prevIndex, h)
|
|
40
|
-
};
|
|
41
|
-
yield {
|
|
42
|
-
type: "hashtag",
|
|
43
|
-
value: text.slice(h + 1, end)
|
|
44
|
-
};
|
|
45
|
-
index = end;
|
|
46
|
-
prevIndex = index;
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
index = h + 1;
|
|
195
|
+
if (block === void 0) {
|
|
196
|
+
scan.lastIndex = start + 1;
|
|
50
197
|
continue;
|
|
51
198
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
try {
|
|
56
|
-
const { data, type } = decode(text.slice(u + 1, end));
|
|
57
|
-
let pointer;
|
|
58
|
-
switch (type) {
|
|
59
|
-
case "npub":
|
|
60
|
-
pointer = { pubkey: data };
|
|
61
|
-
break;
|
|
62
|
-
case "note":
|
|
63
|
-
pointer = { id: data };
|
|
64
|
-
break;
|
|
65
|
-
case "nprofile":
|
|
66
|
-
case "nevent":
|
|
67
|
-
case "naddr":
|
|
68
|
-
pointer = data;
|
|
69
|
-
break;
|
|
70
|
-
case "nsec":
|
|
71
|
-
index = end + 1;
|
|
72
|
-
continue mainloop;
|
|
73
|
-
}
|
|
74
|
-
if (prevIndex !== u - 5) yield {
|
|
75
|
-
type: "text",
|
|
76
|
-
text: text.slice(prevIndex, u - 5)
|
|
77
|
-
};
|
|
78
|
-
yield {
|
|
79
|
-
type: "reference",
|
|
80
|
-
pointer
|
|
81
|
-
};
|
|
82
|
-
index = end;
|
|
83
|
-
prevIndex = index;
|
|
84
|
-
continue;
|
|
85
|
-
} catch {
|
|
86
|
-
index = u + 1;
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
if (u >= 5 && text.slice(u - 5, u) === "https" || u >= 4 && text.slice(u - 4, u) === "http") {
|
|
91
|
-
const m = noURLCharacter.exec(text.slice(u + 4));
|
|
92
|
-
const end = m ? u + 4 + (m.index ?? 0) : max;
|
|
93
|
-
const prefixLen = text[u - 1] === "s" ? 5 : 4;
|
|
94
|
-
try {
|
|
95
|
-
const url = new URL(text.slice(u - prefixLen, end));
|
|
96
|
-
if (!url.hostname.includes(".")) throw new Error("invalid url");
|
|
97
|
-
if (prevIndex !== u - prefixLen) yield {
|
|
98
|
-
type: "text",
|
|
99
|
-
text: text.slice(prevIndex, u - prefixLen)
|
|
100
|
-
};
|
|
101
|
-
const path = url.pathname;
|
|
102
|
-
if (/\.(png|jpe?g|gif|webp|heic|svg)$/i.test(path)) yield {
|
|
103
|
-
type: "image",
|
|
104
|
-
url: url.toString()
|
|
105
|
-
};
|
|
106
|
-
else if (/\.(mp4|avi|webm|mkv|mov)$/i.test(path)) yield {
|
|
107
|
-
type: "video",
|
|
108
|
-
url: url.toString()
|
|
109
|
-
};
|
|
110
|
-
else if (/\.(mp3|aac|ogg|opus|wav|flac)$/i.test(path)) yield {
|
|
111
|
-
type: "audio",
|
|
112
|
-
url: url.toString()
|
|
113
|
-
};
|
|
114
|
-
else yield {
|
|
115
|
-
type: "url",
|
|
116
|
-
url: url.toString()
|
|
117
|
-
};
|
|
118
|
-
index = end;
|
|
119
|
-
prevIndex = index;
|
|
120
|
-
continue;
|
|
121
|
-
} catch {
|
|
122
|
-
index = end + 1;
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
if (u >= 3 && text.slice(u - 3, u) === "wss" || u >= 2 && text.slice(u - 2, u) === "ws") {
|
|
127
|
-
const m = noURLCharacter.exec(text.slice(u + 4));
|
|
128
|
-
const end = m ? u + 4 + (m.index ?? 0) : max;
|
|
129
|
-
const prefixLen = text[u - 1] === "s" ? 3 : 2;
|
|
130
|
-
try {
|
|
131
|
-
const url = new URL(text.slice(u - prefixLen, end));
|
|
132
|
-
if (!url.hostname.includes(".")) throw new Error("invalid ws url");
|
|
133
|
-
if (prevIndex !== u - prefixLen) yield {
|
|
134
|
-
type: "text",
|
|
135
|
-
text: text.slice(prevIndex, u - prefixLen)
|
|
136
|
-
};
|
|
137
|
-
yield {
|
|
138
|
-
type: "relay",
|
|
139
|
-
url: url.toString()
|
|
140
|
-
};
|
|
141
|
-
index = end;
|
|
142
|
-
prevIndex = index;
|
|
143
|
-
continue;
|
|
144
|
-
} catch {
|
|
145
|
-
index = end + 1;
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
for (const emoji of emojis) {
|
|
150
|
-
const endColon = u + emoji.shortcode.length + 1;
|
|
151
|
-
if (text[endColon] === ":" && text.slice(u + 1, endColon) === emoji.shortcode) {
|
|
152
|
-
if (prevIndex !== u) yield {
|
|
153
|
-
type: "text",
|
|
154
|
-
text: text.slice(prevIndex, u)
|
|
155
|
-
};
|
|
156
|
-
yield emoji;
|
|
157
|
-
index = endColon + 1;
|
|
158
|
-
prevIndex = index;
|
|
159
|
-
continue mainloop;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
index = u + 1;
|
|
199
|
+
flushText(start);
|
|
200
|
+
blocks.push(block);
|
|
201
|
+
textStart = block.type === "url" || block.type === "relay" || block.type === "image" || block.type === "video" || block.type === "audio" ? start + block.url.length : end;
|
|
163
202
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
text: text.slice(prevIndex)
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
/** Collect all content blocks into an array. */
|
|
170
|
-
function parseContentBlocks(content) {
|
|
171
|
-
return [...parseContent(content)];
|
|
203
|
+
flushText(text.length);
|
|
204
|
+
return blocks;
|
|
172
205
|
}
|
|
173
206
|
//#endregion
|
|
174
|
-
export { parseContent
|
|
207
|
+
export { parseContent };
|
|
175
208
|
|
|
176
209
|
//# sourceMappingURL=nip27.mjs.map
|
package/dist/nips/nip27.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"nip27.mjs","names":[],"sources":["../../src/nips/nip27.ts"],"sourcesContent":["/**\n * NIP-27: Text Note References Tokenizes note content into text, nostr: references, URLs, hashtags,\n * media, relays, emoji.\n *\n * @see https://github.com/nostr-protocol/nips/blob/master/27.md\n */\nimport type { Event } from \"../core/event.ts\";\nimport { decode } from \"./nip19.ts\";\nimport type { AddressPointer, EventPointer, ProfilePointer } from \"./nip19.ts\";\n\nexport type ContentBlock =\n | { type: \"text\"; text: string }\n | { type: \"reference\"; pointer: ProfilePointer | AddressPointer | EventPointer }\n | { type: \"url\"; url: string }\n | { type: \"relay\"; url: string }\n | { type: \"image\"; url: string }\n | { type: \"video\"; url: string }\n | { type: \"audio\"; url: string }\n | { type: \"emoji\"; shortcode: string; url: string }\n | { type: \"hashtag\"; value: string };\n\nconst noCharacter = /\\W/m;\nconst noURLCharacter = /[^\\w/] |[^\\w/]$|$|,| /m;\nconst MAX_HASHTAG_LENGTH = 42;\n\n/**\n * Parse note content (or a full event, to pick up emoji tags) into blocks. Generator — consume with\n * `for…of` or `[...parseContent(s)]`.\n */\nexport function* parseContent(\n content: string | Pick<Event, \"content\" | \"tags\">,\n): Iterable<ContentBlock> {\n const emojis: Array<{ type: \"emoji\"; shortcode: string; url: string }> = [];\n let text: string;\n\n if (typeof content === \"string\") {\n text = content;\n } else {\n for (const tag of content.tags) {\n const shortcode = tag.at(1);\n const url = tag.at(2);\n if (\n tag[0] === \"emoji\" &&\n shortcode !== undefined &&\n shortcode !== \"\" &&\n url !== undefined &&\n url !== \"\"\n ) {\n emojis.push({ type: \"emoji\", shortcode, url });\n }\n }\n text = content.content;\n }\n\n const max = text.length;\n let prevIndex = 0;\n let index = 0;\n\n mainloop: while (index < max) {\n const u = text.indexOf(\":\", index);\n const h = text.indexOf(\"#\", index);\n if (u === -1 && h === -1) {\n break;\n }\n\n if (u === -1 || (h !== -1 && h < u)) {\n // hashtag\n if (h === 0 || (h > 0 && noCharacter.exec(text.at(h - 1) ?? \"\") !== null)) {\n const m = noCharacter.exec(text.slice(h + 1, h + MAX_HASHTAG_LENGTH));\n const end = m ? h + 1 + (m.index ?? 0) : max;\n if (prevIndex !== h) {\n yield { type: \"text\", text: text.slice(prevIndex, h) };\n }\n yield { type: \"hashtag\", value: text.slice(h + 1, end) };\n index = end;\n prevIndex = index;\n continue;\n }\n index = h + 1;\n continue;\n }\n\n // nostr: references\n if (u >= 5 && text.slice(u - 5, u) === \"nostr\") {\n const m = noCharacter.exec(text.slice(u + 60));\n const end = m ? u + 60 + (m.index ?? 0) : max;\n try {\n const { data, type } = decode(text.slice(u + 1, end));\n let pointer: ProfilePointer | AddressPointer | EventPointer | undefined;\n switch (type) {\n case \"npub\":\n pointer = { pubkey: data };\n break;\n case \"note\":\n pointer = { id: data };\n break;\n case \"nprofile\":\n case \"nevent\":\n case \"naddr\":\n pointer = data;\n break;\n case \"nsec\":\n index = end + 1;\n continue mainloop;\n }\n if (prevIndex !== u - 5) {\n yield { type: \"text\", text: text.slice(prevIndex, u - 5) };\n }\n yield { type: \"reference\", pointer };\n index = end;\n prevIndex = index;\n continue;\n } catch {\n index = u + 1;\n continue;\n }\n }\n\n // http(s) URLs\n if (\n (u >= 5 && text.slice(u - 5, u) === \"https\") ||\n (u >= 4 && text.slice(u - 4, u) === \"http\")\n ) {\n const m = noURLCharacter.exec(text.slice(u + 4));\n const end = m ? u + 4 + (m.index ?? 0) : max;\n const prefixLen = text[u - 1] === \"s\" ? 5 : 4;\n try {\n const url = new URL(text.slice(u - prefixLen, end));\n if (!url.hostname.includes(\".\")) {\n throw new Error(\"invalid url\");\n }\n if (prevIndex !== u - prefixLen) {\n yield { type: \"text\", text: text.slice(prevIndex, u - prefixLen) };\n }\n const path = url.pathname;\n if (/\\.(png|jpe?g|gif|webp|heic|svg)$/i.test(path)) {\n yield { type: \"image\", url: url.toString() };\n } else if (/\\.(mp4|avi|webm|mkv|mov)$/i.test(path)) {\n yield { type: \"video\", url: url.toString() };\n } else if (/\\.(mp3|aac|ogg|opus|wav|flac)$/i.test(path)) {\n yield { type: \"audio\", url: url.toString() };\n } else {\n yield { type: \"url\", url: url.toString() };\n }\n index = end;\n prevIndex = index;\n continue;\n } catch {\n index = end + 1;\n continue;\n }\n }\n\n // ws(s) relays\n if ((u >= 3 && text.slice(u - 3, u) === \"wss\") || (u >= 2 && text.slice(u - 2, u) === \"ws\")) {\n const m = noURLCharacter.exec(text.slice(u + 4));\n const end = m ? u + 4 + (m.index ?? 0) : max;\n const prefixLen = text[u - 1] === \"s\" ? 3 : 2;\n try {\n const url = new URL(text.slice(u - prefixLen, end));\n if (!url.hostname.includes(\".\")) {\n throw new Error(\"invalid ws url\");\n }\n if (prevIndex !== u - prefixLen) {\n yield { type: \"text\", text: text.slice(prevIndex, u - prefixLen) };\n }\n yield { type: \"relay\", url: url.toString() };\n index = end;\n prevIndex = index;\n continue;\n } catch {\n index = end + 1;\n continue;\n }\n }\n\n // custom emoji shortcodes :code:\n for (const emoji of emojis) {\n const endColon = u + emoji.shortcode.length + 1;\n if (text[endColon] === \":\" && text.slice(u + 1, endColon) === emoji.shortcode) {\n if (prevIndex !== u) {\n yield { type: \"text\", text: text.slice(prevIndex, u) };\n }\n yield emoji;\n index = endColon + 1;\n prevIndex = index;\n continue mainloop;\n }\n }\n\n index = u + 1;\n }\n\n if (prevIndex !== max) {\n yield { type: \"text\", text: text.slice(prevIndex) };\n }\n}\n\n/** Collect all content blocks into an array. */\nexport function parseContentBlocks(\n content: string | Pick<Event, \"content\" | \"tags\">,\n): ContentBlock[] {\n return [...parseContent(content)];\n}\n"],"mappings":";;AAqBA,MAAM,cAAc;AACpB,MAAM,iBAAiB;AACvB,MAAM,qBAAqB;;;;;AAM3B,UAAiB,aACf,SACwB;CACxB,MAAM,SAAmE,CAAC;CAC1E,IAAI;CAEJ,IAAI,OAAO,YAAY,UACrB,OAAO;MACF;EACL,KAAK,MAAM,OAAO,QAAQ,MAAM;GAC9B,MAAM,YAAY,IAAI,GAAG,CAAC;GAC1B,MAAM,MAAM,IAAI,GAAG,CAAC;GACpB,IACE,IAAI,OAAO,WACX,cAAc,KAAA,KACd,cAAc,MACd,QAAQ,KAAA,KACR,QAAQ,IAER,OAAO,KAAK;IAAE,MAAM;IAAS;IAAW;GAAI,CAAC;EAEjD;EACA,OAAO,QAAQ;CACjB;CAEA,MAAM,MAAM,KAAK;CACjB,IAAI,YAAY;CAChB,IAAI,QAAQ;CAEZ,UAAU,OAAO,QAAQ,KAAK;EAC5B,MAAM,IAAI,KAAK,QAAQ,KAAK,KAAK;EACjC,MAAM,IAAI,KAAK,QAAQ,KAAK,KAAK;EACjC,IAAI,MAAM,MAAM,MAAM,IACpB;EAGF,IAAI,MAAM,MAAO,MAAM,MAAM,IAAI,GAAI;GAEnC,IAAI,MAAM,KAAM,IAAI,KAAK,YAAY,KAAK,KAAK,GAAG,IAAI,CAAC,KAAK,EAAE,MAAM,MAAO;IACzE,MAAM,IAAI,YAAY,KAAK,KAAK,MAAM,IAAI,GAAG,IAAI,kBAAkB,CAAC;IACpE,MAAM,MAAM,IAAI,IAAI,KAAK,EAAE,SAAS,KAAK;IACzC,IAAI,cAAc,GAChB,MAAM;KAAE,MAAM;KAAQ,MAAM,KAAK,MAAM,WAAW,CAAC;IAAE;IAEvD,MAAM;KAAE,MAAM;KAAW,OAAO,KAAK,MAAM,IAAI,GAAG,GAAG;IAAE;IACvD,QAAQ;IACR,YAAY;IACZ;GACF;GACA,QAAQ,IAAI;GACZ;EACF;EAGA,IAAI,KAAK,KAAK,KAAK,MAAM,IAAI,GAAG,CAAC,MAAM,SAAS;GAC9C,MAAM,IAAI,YAAY,KAAK,KAAK,MAAM,IAAI,EAAE,CAAC;GAC7C,MAAM,MAAM,IAAI,IAAI,MAAM,EAAE,SAAS,KAAK;GAC1C,IAAI;IACF,MAAM,EAAE,MAAM,SAAS,OAAO,KAAK,MAAM,IAAI,GAAG,GAAG,CAAC;IACpD,IAAI;IACJ,QAAQ,MAAR;KACE,KAAK;MACH,UAAU,EAAE,QAAQ,KAAK;MACzB;KACF,KAAK;MACH,UAAU,EAAE,IAAI,KAAK;MACrB;KACF,KAAK;KACL,KAAK;KACL,KAAK;MACH,UAAU;MACV;KACF,KAAK;MACH,QAAQ,MAAM;MACd,SAAS;IACb;IACA,IAAI,cAAc,IAAI,GACpB,MAAM;KAAE,MAAM;KAAQ,MAAM,KAAK,MAAM,WAAW,IAAI,CAAC;IAAE;IAE3D,MAAM;KAAE,MAAM;KAAa;IAAQ;IACnC,QAAQ;IACR,YAAY;IACZ;GACF,QAAQ;IACN,QAAQ,IAAI;IACZ;GACF;EACF;EAGA,IACG,KAAK,KAAK,KAAK,MAAM,IAAI,GAAG,CAAC,MAAM,WACnC,KAAK,KAAK,KAAK,MAAM,IAAI,GAAG,CAAC,MAAM,QACpC;GACA,MAAM,IAAI,eAAe,KAAK,KAAK,MAAM,IAAI,CAAC,CAAC;GAC/C,MAAM,MAAM,IAAI,IAAI,KAAK,EAAE,SAAS,KAAK;GACzC,MAAM,YAAY,KAAK,IAAI,OAAO,MAAM,IAAI;GAC5C,IAAI;IACF,MAAM,MAAM,IAAI,IAAI,KAAK,MAAM,IAAI,WAAW,GAAG,CAAC;IAClD,IAAI,CAAC,IAAI,SAAS,SAAS,GAAG,GAC5B,MAAM,IAAI,MAAM,aAAa;IAE/B,IAAI,cAAc,IAAI,WACpB,MAAM;KAAE,MAAM;KAAQ,MAAM,KAAK,MAAM,WAAW,IAAI,SAAS;IAAE;IAEnE,MAAM,OAAO,IAAI;IACjB,IAAI,oCAAoC,KAAK,IAAI,GAC/C,MAAM;KAAE,MAAM;KAAS,KAAK,IAAI,SAAS;IAAE;SACtC,IAAI,6BAA6B,KAAK,IAAI,GAC/C,MAAM;KAAE,MAAM;KAAS,KAAK,IAAI,SAAS;IAAE;SACtC,IAAI,kCAAkC,KAAK,IAAI,GACpD,MAAM;KAAE,MAAM;KAAS,KAAK,IAAI,SAAS;IAAE;SAE3C,MAAM;KAAE,MAAM;KAAO,KAAK,IAAI,SAAS;IAAE;IAE3C,QAAQ;IACR,YAAY;IACZ;GACF,QAAQ;IACN,QAAQ,MAAM;IACd;GACF;EACF;EAGA,IAAK,KAAK,KAAK,KAAK,MAAM,IAAI,GAAG,CAAC,MAAM,SAAW,KAAK,KAAK,KAAK,MAAM,IAAI,GAAG,CAAC,MAAM,MAAO;GAC3F,MAAM,IAAI,eAAe,KAAK,KAAK,MAAM,IAAI,CAAC,CAAC;GAC/C,MAAM,MAAM,IAAI,IAAI,KAAK,EAAE,SAAS,KAAK;GACzC,MAAM,YAAY,KAAK,IAAI,OAAO,MAAM,IAAI;GAC5C,IAAI;IACF,MAAM,MAAM,IAAI,IAAI,KAAK,MAAM,IAAI,WAAW,GAAG,CAAC;IAClD,IAAI,CAAC,IAAI,SAAS,SAAS,GAAG,GAC5B,MAAM,IAAI,MAAM,gBAAgB;IAElC,IAAI,cAAc,IAAI,WACpB,MAAM;KAAE,MAAM;KAAQ,MAAM,KAAK,MAAM,WAAW,IAAI,SAAS;IAAE;IAEnE,MAAM;KAAE,MAAM;KAAS,KAAK,IAAI,SAAS;IAAE;IAC3C,QAAQ;IACR,YAAY;IACZ;GACF,QAAQ;IACN,QAAQ,MAAM;IACd;GACF;EACF;EAGA,KAAK,MAAM,SAAS,QAAQ;GAC1B,MAAM,WAAW,IAAI,MAAM,UAAU,SAAS;GAC9C,IAAI,KAAK,cAAc,OAAO,KAAK,MAAM,IAAI,GAAG,QAAQ,MAAM,MAAM,WAAW;IAC7E,IAAI,cAAc,GAChB,MAAM;KAAE,MAAM;KAAQ,MAAM,KAAK,MAAM,WAAW,CAAC;IAAE;IAEvD,MAAM;IACN,QAAQ,WAAW;IACnB,YAAY;IACZ,SAAS;GACX;EACF;EAEA,QAAQ,IAAI;CACd;CAEA,IAAI,cAAc,KAChB,MAAM;EAAE,MAAM;EAAQ,MAAM,KAAK,MAAM,SAAS;CAAE;AAEtD;;AAGA,SAAgB,mBACd,SACgB;CAChB,OAAO,CAAC,GAAG,aAAa,OAAO,CAAC;AAClC"}
|
|
1
|
+
{"version":3,"file":"nip27.mjs","names":[],"sources":["../../src/nips/nip27.ts"],"sourcesContent":["/**\n * NIP-27: Text Note References. Tokenizes note content into text, `nostr:` references, URLs, media,\n * relays, hashtags, custom emoji, and Lightning invoices.\n *\n * @see https://github.com/nostr-protocol/nips/blob/master/27.md\n */\nimport type { Event } from \"../core/event.ts\";\nimport { decode } from \"./nip19.ts\";\nimport type { AddressPointer, EventPointer, ProfilePointer } from \"./nip19.ts\";\n\nexport type ContentBlock =\n | { type: \"text\"; text: string }\n | {\n type: \"reference\";\n pointer: ProfilePointer | EventPointer | AddressPointer;\n /**\n * `true` when the reference had no `nostr:` prefix (see\n * {@link ParseContentOptions.legacyBech32}).\n */\n bare: boolean;\n }\n | { type: \"url\"; url: string }\n | { type: \"image\" | \"video\" | \"audio\"; url: string }\n | { type: \"relay\"; url: string }\n | { type: \"hashtag\"; value: string }\n | { type: \"emoji\"; shortcode: string; url: string }\n | { type: \"invoice\"; bolt11: string };\n\nexport type ParseContentOptions = {\n /**\n * Recognize bare `npub`, `nprofile`, `note`, `nevent`, `naddr` without the `nostr:` prefix.\n * Default `false`.\n */\n legacyBech32?: boolean;\n /**\n * URL → MIME type (the `m` field of NIP-92 `imeta` tags); takes precedence over extension\n * classification.\n */\n imeta?: ReadonlyMap<string, string>;\n};\n\n// Bech32 data charset ([02-9ac-hj-np-z], case-insensitive) and the NIP-19 entity prefixes.\nconst BECH32_CHARS = \"02-9ac-hj-np-z\";\nconst NIP19_PREFIX = \"(?:npub|nprofile|note|nevent|naddr|nsec)1\";\nconst NIP19_BARE_PREFIX = \"(?:npub|nprofile|note|nevent|naddr)1\";\n\n// A URL run ends at whitespace, angle brackets, double quotes, CJK punctuation\n// (U+3000–U+303F), or full-width forms (U+FF00–U+FFEF).\nconst URL_RUN = `[^\\\\s<>\"\\\\u3000-\\\\u303f\\\\uff00-\\\\uffef]+`;\n\n// Rule order matters: at every position the first matching alternative wins.\nconst SCAN_BODY = [\n // 1. nostr: URIs carry a NIP-19 entity.\n `(?<nostr>nostr:${NIP19_PREFIX}[${BECH32_CHARS}]+)`,\n // 2./3. http(s) and ws(s) URLs share the same boundary rules.\n `(?<url>https?://${URL_RUN})`,\n `(?<relay>wss?://${URL_RUN})`,\n // 4. BOLT11 invoices, with an optional lightning: URI prefix. The HRP may carry an amount\n // (`lnbc10u1…` = ln + currency + amount + multiplier + the `1` separator).\n `(?<invoice>(?:lightning:)?ln(?:bcrt|tbs|bc|tb)(?:\\\\d+[munp]?)?1[${BECH32_CHARS}]+)`,\n // 5. Hashtags: Unicode letters/marks/numbers and underscore, at most 42 code points.\n `(?<hashtag>#[\\\\p{L}\\\\p{M}\\\\p{N}_]{1,42})`,\n // 6. Custom emoji shortcodes (validated against the event's emoji tags).\n `(?<emoji>:[A-Za-z0-9_]+:)`,\n].join(\"|\");\n\nconst SCAN = new RegExp(SCAN_BODY, \"giu\");\n// 7. Bare NIP-19 entities — legacy rendering support, opt-in only.\nconst SCAN_LEGACY = new RegExp(\n `${SCAN_BODY}|(?<bare>${NIP19_BARE_PREFIX}[${BECH32_CHARS}]+)`,\n \"giu\",\n);\n\n// The character before `#` must be absent or a non-tag character.\nconst TAG_CHAR = /[\\p{L}\\p{M}\\p{N}_]/u;\n// Invoice and bare-bech32 tokens must not be glued to a letter or digit.\nconst WORD_CHAR = /[\\p{L}\\p{N}]/u;\n\nconst TRAILING_URL_CHARS = new Set([\".\", \",\", \";\", \":\", \"!\", \"?\", \"'\"]);\n\nconst IMAGE_EXT = new Set([\"jpg\", \"jpeg\", \"png\", \"gif\", \"webp\", \"avif\", \"svg\"]);\nconst VIDEO_EXT = new Set([\"mp4\", \"webm\", \"mov\", \"m4v\", \"m3u8\"]);\nconst AUDIO_EXT = new Set([\"mp3\", \"m4a\", \"ogg\", \"oga\", \"opus\", \"wav\", \"flac\", \"aac\"]);\n\nfunction isChar(cp: number | undefined, re: RegExp): boolean {\n return cp !== undefined && re.test(String.fromCodePoint(cp));\n}\n\n/** Drop trailing sentence punctuation and closers that have no opener inside the URL. */\nfunction trimUrlTail(raw: string): string {\n let url = raw;\n for (;;) {\n const last = url.at(-1);\n if (last !== undefined && TRAILING_URL_CHARS.has(last)) {\n url = url.slice(0, -1);\n continue;\n }\n if (last === \")\" || last === \"]\") {\n const open = last === \")\" ? \"(\" : \"[\";\n let opens = 0;\n let closes = 0;\n for (const c of url) {\n if (c === open) {\n opens++;\n } else if (c === last) {\n closes++;\n }\n }\n if (closes > opens) {\n url = url.slice(0, -1);\n continue;\n }\n }\n return url;\n }\n}\n\ntype MediaType = \"image\" | \"video\" | \"audio\";\n\nfunction mimeMedia(mime: string | undefined): MediaType | undefined {\n if (mime === undefined) {\n return undefined;\n }\n const slash = mime.indexOf(\"/\");\n const base = slash === -1 ? mime : mime.slice(0, slash);\n return base === \"image\" || base === \"video\" || base === \"audio\" ? base : undefined;\n}\n\nfunction extMedia(pathname: string): MediaType | undefined {\n const dot = pathname.lastIndexOf(\".\");\n if (dot === -1) {\n return undefined;\n }\n const ext = pathname.slice(dot + 1).toLowerCase();\n if (IMAGE_EXT.has(ext)) {\n return \"image\";\n }\n if (VIDEO_EXT.has(ext)) {\n return \"video\";\n }\n return AUDIO_EXT.has(ext) ? \"audio\" : undefined;\n}\n\nfunction urlBlock(\n raw: string,\n relay: boolean,\n imeta: ReadonlyMap<string, string> | undefined,\n): ContentBlock | undefined {\n const url = trimUrlTail(raw);\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n return undefined;\n }\n if (!parsed.hostname.includes(\".\")) {\n return undefined;\n }\n if (relay) {\n return { type: \"relay\", url };\n }\n const media = mimeMedia(imeta?.get(url)) ?? extMedia(parsed.pathname);\n return media === undefined ? { type: \"url\", url } : { type: media, url };\n}\n\nfunction referenceFor(code: string, bare: boolean): ContentBlock | undefined {\n let pointer: ProfilePointer | EventPointer | AddressPointer;\n try {\n const decoded = decode(code);\n switch (decoded.type) {\n case \"npub\":\n pointer = { pubkey: decoded.data };\n break;\n case \"note\":\n pointer = { id: decoded.data };\n break;\n case \"nprofile\":\n case \"nevent\":\n case \"naddr\":\n pointer = decoded.data;\n break;\n case \"nsec\":\n return undefined;\n }\n } catch {\n return undefined;\n }\n return { type: \"reference\", pointer, bare };\n}\n\n/**\n * Parse note content (or a full event, to pick up `emoji` tags) into ordered blocks. One\n * left-to-right scan; at every position the rules fire in the alternation order above. Adjacent\n * text merges and no empty text blocks are emitted.\n */\nexport function parseContent(\n content: string | Pick<Event, \"content\" | \"tags\">,\n opts?: ParseContentOptions,\n): ContentBlock[] {\n const emojis = new Map<string, string>();\n let text: string;\n if (typeof content === \"string\") {\n text = content;\n } else {\n for (const tag of content.tags) {\n if (\n tag[0] === \"emoji\" &&\n tag[1] !== undefined &&\n tag[1] !== \"\" &&\n tag[2] !== undefined &&\n tag[2] !== \"\"\n ) {\n emojis.set(tag[1], tag[2]);\n }\n }\n text = content.content;\n }\n\n const scan = opts?.legacyBech32 === true ? SCAN_LEGACY : SCAN;\n scan.lastIndex = 0;\n\n const blocks: ContentBlock[] = [];\n let textStart = 0;\n const flushText = (end: number): void => {\n if (end > textStart) {\n blocks.push({ type: \"text\", text: text.slice(textStart, end) });\n }\n };\n\n for (let m = scan.exec(text); m !== null; m = scan.exec(text)) {\n const start = m.index;\n const [token] = m;\n const end = start + token.length;\n const g = m.groups;\n let block: ContentBlock | undefined;\n\n if (g?.[\"nostr\"] !== undefined) {\n block = referenceFor(token.slice(\"nostr:\".length), false);\n } else if (g?.[\"url\"] !== undefined) {\n block = urlBlock(token, false, opts?.imeta);\n } else if (g?.[\"relay\"] !== undefined) {\n block = urlBlock(token, true, undefined);\n } else if (g?.[\"invoice\"] !== undefined) {\n const prev = start === 0 ? undefined : text.codePointAt(start - 1);\n if (!isChar(prev, WORD_CHAR) && !isChar(text.codePointAt(end), WORD_CHAR)) {\n const invoice = token.toLowerCase().startsWith(\"lightning:\")\n ? token.slice(\"lightning:\".length)\n : token;\n block = { type: \"invoice\", bolt11: invoice.toLowerCase() };\n }\n } else if (g?.[\"hashtag\"] !== undefined) {\n const prev = start === 0 ? undefined : text.codePointAt(start - 1);\n if (!isChar(prev, TAG_CHAR)) {\n block = { type: \"hashtag\", value: token.slice(1) };\n }\n } else if (g?.[\"emoji\"] !== undefined) {\n const url = emojis.get(token.slice(1, -1));\n if (url !== undefined) {\n block = { type: \"emoji\", shortcode: token.slice(1, -1), url };\n }\n } else if (g?.[\"bare\"] !== undefined) {\n const prev = start === 0 ? undefined : text.codePointAt(start - 1);\n if (!isChar(prev, WORD_CHAR) && !isChar(text.codePointAt(end), WORD_CHAR)) {\n block = referenceFor(token, true);\n }\n }\n\n if (block === undefined) {\n // The candidate is plain text; resume scanning one code unit later so any token inside\n // its span can still match.\n scan.lastIndex = start + 1;\n continue;\n }\n flushText(start);\n blocks.push(block);\n // URL-family blocks may end before the regex run once trailing punctuation is trimmed.\n textStart =\n block.type === \"url\" ||\n block.type === \"relay\" ||\n block.type === \"image\" ||\n block.type === \"video\" ||\n block.type === \"audio\"\n ? start + block.url.length\n : end;\n }\n\n flushText(text.length);\n return blocks;\n}\n"],"mappings":"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"}
|
package/dist/nips/nip57.d.mts
CHANGED
|
@@ -29,8 +29,14 @@ export type ZapReceiptValidation = {
|
|
|
29
29
|
};
|
|
30
30
|
export type Bolt11Fields = {
|
|
31
31
|
amountMsats?: number;
|
|
32
|
+
/** The `d` tagged field, UTF-8 decoded; omitted on invalid UTF-8. */
|
|
33
|
+
description?: string;
|
|
32
34
|
descriptionHash?: Uint8Array;
|
|
33
35
|
paymentHash?: Uint8Array;
|
|
36
|
+
/** Invoice creation time (unix seconds). */
|
|
37
|
+
timestamp: number;
|
|
38
|
+
/** `x` tagged field — seconds after `timestamp` until expiry. Defaults to 3600 when absent. */
|
|
39
|
+
expiry: number;
|
|
34
40
|
};
|
|
35
41
|
/** Decode a BOLT11 invoice. Never throws. Requires a 32-byte payment hash (type 1). */
|
|
36
42
|
export declare function parseBolt11(pr: string): Bolt11Fields | undefined;
|
package/dist/nips/nip57.mjs
CHANGED
|
@@ -103,13 +103,29 @@ const BOLT11_TIMESTAMP_WORDS = 7;
|
|
|
103
103
|
const BOLT11_SIGNATURE_WORDS = 104;
|
|
104
104
|
const BOLT11_HASH_WORDS = 52;
|
|
105
105
|
const HASH_BYTES = 32;
|
|
106
|
+
const BOLT11_TAG_PAYMENT_HASH = 1;
|
|
107
|
+
const BOLT11_TAG_EXPIRY = 6;
|
|
108
|
+
const BOLT11_TAG_DESCRIPTION = 13;
|
|
109
|
+
const BOLT11_TAG_DESCRIPTION_HASH = 23;
|
|
110
|
+
const DEFAULT_EXPIRY_SECONDS = 3600;
|
|
111
|
+
const utf8DecoderFatal = new TextDecoder("utf-8", { fatal: true });
|
|
112
|
+
/** Big-endian 5-bit words as a JS number (safe below 2^53). */
|
|
113
|
+
function wordsToInt(words) {
|
|
114
|
+
let value = 0;
|
|
115
|
+
for (const word of words) value = value * 32 + word;
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
106
118
|
/** Decode a BOLT11 invoice. Never throws. Requires a 32-byte payment hash (type 1). */
|
|
107
119
|
function parseBolt11(pr) {
|
|
108
120
|
try {
|
|
109
121
|
const { prefix, words } = bech32.decode(pr.toLowerCase(), false);
|
|
110
122
|
if (!prefix.startsWith("ln")) return;
|
|
111
123
|
if (words.length < 111) return;
|
|
112
|
-
const fields = {
|
|
124
|
+
const fields = {
|
|
125
|
+
timestamp: wordsToInt(words.slice(0, BOLT11_TIMESTAMP_WORDS)),
|
|
126
|
+
expiry: DEFAULT_EXPIRY_SECONDS
|
|
127
|
+
};
|
|
128
|
+
let sawExpiry = false;
|
|
113
129
|
const amountMsats = amountMsatsFromHrp(prefix);
|
|
114
130
|
if (amountMsats !== void 0) fields.amountMsats = amountMsats;
|
|
115
131
|
const tlvEnd = words.length - BOLT11_SIGNATURE_WORDS;
|
|
@@ -124,10 +140,24 @@ function parseBolt11(pr) {
|
|
|
124
140
|
if (i + dataLen > tlvEnd) break;
|
|
125
141
|
const data = words.slice(i, i + dataLen);
|
|
126
142
|
i += dataLen;
|
|
127
|
-
if (type
|
|
143
|
+
if (type === BOLT11_TAG_EXPIRY) {
|
|
144
|
+
if (!sawExpiry) {
|
|
145
|
+
sawExpiry = true;
|
|
146
|
+
fields.expiry = wordsToInt(data);
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (type === BOLT11_TAG_DESCRIPTION) {
|
|
151
|
+
const bytes = bech32.fromWordsUnsafe(data);
|
|
152
|
+
if (bytes !== void 0) try {
|
|
153
|
+
fields.description ??= utf8DecoderFatal.decode(bytes);
|
|
154
|
+
} catch {}
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (type !== BOLT11_TAG_PAYMENT_HASH && type !== BOLT11_TAG_DESCRIPTION_HASH || dataLen !== BOLT11_HASH_WORDS) continue;
|
|
128
158
|
const bytes = bech32.fromWordsUnsafe(data);
|
|
129
159
|
if (!bytes || bytes.length !== HASH_BYTES) continue;
|
|
130
|
-
if (type ===
|
|
160
|
+
if (type === BOLT11_TAG_PAYMENT_HASH) fields.paymentHash ??= bytes;
|
|
131
161
|
else fields.descriptionHash ??= bytes;
|
|
132
162
|
}
|
|
133
163
|
if (!fields.paymentHash) return;
|
package/dist/nips/nip57.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"nip57.mjs","names":[],"sources":["../../src/nips/nip57.ts"],"sourcesContent":["/**\n * NIP-57 Lightning Zaps — zap request template (kind 9734) and receipt validation (kind 9735). Does\n * not fetch LNURL. Receipt checks never throw.\n *\n * @see https://github.com/nostr-protocol/nips/blob/master/57.md\n */\nimport { equalBytes } from \"@noble/ciphers/utils.js\";\nimport { sha256 } from \"@noble/hashes/sha2.js\";\nimport { bech32 } from \"@scure/base\";\n\nimport { EventValidationError } from \"../core/error.ts\";\nimport { validateSignedEvent } from \"../core/event.ts\";\nimport type { Event, EventTemplate } from \"../core/event.ts\";\nimport { isAddressableKind, Kind } from \"../core/kind.ts\";\nimport { eventAddress, getDTag, parseEventAddress, Tag } from \"../core/tag.ts\";\nimport { hexToBytes, utf8Encoder } from \"../core/util.ts\";\nimport { verifyEvent } from \"../core/verifier.ts\";\n\nexport type ProfileZapRequest = {\n pubkey: string;\n /** Amount in millisats. */\n amount: number;\n relays: ReadonlyArray<string>;\n comment?: string;\n lnurl?: string;\n};\n\nexport type EventZapRequest = {\n event: Event;\n /** Amount in millisats. */\n amount: number;\n relays: ReadonlyArray<string>;\n comment?: string;\n lnurl?: string;\n};\n\nexport function makeZapRequest(params: ProfileZapRequest | EventZapRequest): EventTemplate {\n if (params.relays.length === 0) {\n throw new EventValidationError(\"relays tag requires one or more URLs\");\n }\n\n const recipient = \"event\" in params ? params.event.pubkey : params.pubkey;\n const tags: Tag[] = [\n Tag.p(recipient),\n [\"amount\", params.amount.toString()],\n [\"relays\", ...params.relays],\n ];\n\n if (\"event\" in params) {\n const { event } = params;\n tags.push(Tag.e(event.id));\n if (isAddressableKind(event.kind)) {\n const d = getDTag(event.tags);\n if (d === undefined) {\n throw new EventValidationError(\"d tag not found\");\n }\n const addr = eventAddress(event);\n if (addr !== undefined) {\n tags.push([\"a\", addr]);\n }\n }\n tags.push([\"k\", event.kind.toString()]);\n }\n\n if (params.lnurl !== undefined && params.lnurl !== \"\") {\n tags.push([\"lnurl\", params.lnurl]);\n }\n\n return {\n kind: Kind.ZapRequest,\n created_at: Math.floor(Date.now() / 1000),\n content: params.comment ?? \"\",\n tags,\n };\n}\n\nexport type ZapReceiptContext = {\n nostrPubkey: string;\n lnurl?: string;\n};\n\nexport type ZapReceiptValidation = {\n valid: boolean;\n reason?: string;\n request?: Event;\n amountMsats?: number;\n};\n\nexport type Bolt11Fields = {\n amountMsats?: number;\n descriptionHash?: Uint8Array;\n paymentHash?: Uint8Array;\n};\n\nfunction firstTagValue(tags: ReadonlyArray<Tag>, name: string): string | undefined {\n for (const tag of tags) {\n if (tag[0] === name && tag[1] !== undefined) {\n return tag[1];\n }\n }\n return undefined;\n}\n\nfunction fail(reason: string): ZapReceiptValidation {\n return { valid: false, reason };\n}\n\nconst MSATS_PER_BTC = 100_000_000_000;\nconst MSATS_PER_MILLI = 100_000_000;\nconst MSATS_PER_MICRO = 100_000;\nconst MSATS_PER_NANO = 100;\n\n/** Amount is digits after `ln` + currency letters, optional m/u/n/p. Non-integer pico is omitted. */\nfunction amountMsatsFromHrp(hrp: string): number | undefined {\n if (!hrp.startsWith(\"ln\")) {\n return undefined;\n }\n let i = 2;\n while (i < hrp.length) {\n const c = hrp.codePointAt(i);\n if (c === undefined || c < 97 || c > 122) {\n break;\n }\n i++;\n }\n if (i === hrp.length) {\n return undefined;\n }\n const rest = hrp.slice(i);\n const m = /^([0-9]+)([munp])?$/.exec(rest);\n if (!m) {\n return undefined;\n }\n const n = Number(m[1]);\n if (!Number.isSafeInteger(n)) {\n return undefined;\n }\n const mul = m.at(2);\n if (mul === undefined) {\n return n * MSATS_PER_BTC;\n }\n if (mul === \"m\") {\n return n * MSATS_PER_MILLI;\n }\n if (mul === \"u\") {\n return n * MSATS_PER_MICRO;\n }\n if (mul === \"n\") {\n return n * MSATS_PER_NANO;\n }\n // p: 0.1 msat; drop amounts that are not whole millisats\n if (n % 10 !== 0) {\n return undefined;\n }\n return n / 10;\n}\n\nfunction parseMsatsTag(value: string): number | undefined {\n if (!/^[0-9]+$/.test(value)) {\n return undefined;\n }\n const n = Number(value);\n return Number.isSafeInteger(n) ? n : undefined;\n}\n\nfunction countTags(tags: ReadonlyArray<Tag>, name: string): number {\n let n = 0;\n for (const tag of tags) {\n if (tag[0] === name) {\n n++;\n }\n }\n return n;\n}\n\nfunction hasHexTagValue(tags: ReadonlyArray<Tag>, name: string, value: string): boolean {\n const needle = value.toLowerCase();\n for (const tag of tags) {\n if (tag[0] === name && tag[1] !== undefined && tag[1].toLowerCase() === needle) {\n return true;\n }\n }\n return false;\n}\n\nfunction hasAddressTag(tags: ReadonlyArray<Tag>, value: string): boolean {\n const want = parseEventAddress(value);\n if (!want) {\n return false;\n }\n for (const tag of tags) {\n if (tag[0] !== \"a\" || tag[1] === undefined) {\n continue;\n }\n const got = parseEventAddress(tag[1]);\n if (\n got &&\n got.kind === want.kind &&\n got.pubkey === want.pubkey &&\n got.identifier === want.identifier\n ) {\n return true;\n }\n }\n return false;\n}\n\nconst BOLT11_TIMESTAMP_WORDS = 7;\nconst BOLT11_SIGNATURE_WORDS = 104;\nconst BOLT11_HASH_WORDS = 52;\nconst HASH_BYTES = 32;\n\n/** Decode a BOLT11 invoice. Never throws. Requires a 32-byte payment hash (type 1). */\nexport function parseBolt11(pr: string): Bolt11Fields | undefined {\n try {\n // Invoices exceed bech32's default 90-char limit.\n const { prefix, words } = bech32.decode(pr.toLowerCase(), false);\n if (!prefix.startsWith(\"ln\")) {\n return undefined;\n }\n // timestamp (7) + tagged fields + secp256k1 signature (104)\n if (words.length < BOLT11_TIMESTAMP_WORDS + BOLT11_SIGNATURE_WORDS) {\n return undefined;\n }\n const fields: Bolt11Fields = {};\n const amountMsats = amountMsatsFromHrp(prefix);\n if (amountMsats !== undefined) {\n fields.amountMsats = amountMsats;\n }\n const tlvEnd = words.length - BOLT11_SIGNATURE_WORDS;\n let i = BOLT11_TIMESTAMP_WORDS;\n while (i + 3 <= tlvEnd) {\n const type = words[i];\n const lenHigh = words[i + 1];\n const lenLow = words[i + 2];\n if (type === undefined || lenHigh === undefined || lenLow === undefined) {\n break;\n }\n const dataLen = lenHigh * 32 + lenLow;\n i += 3;\n if (i + dataLen > tlvEnd) {\n break;\n }\n const data = words.slice(i, i + dataLen);\n i += dataLen;\n if ((type !== 1 && type !== 23) || dataLen !== BOLT11_HASH_WORDS) {\n continue;\n }\n const bytes = bech32.fromWordsUnsafe(data);\n if (!bytes || bytes.length !== HASH_BYTES) {\n continue;\n }\n if (type === 1) {\n fields.paymentHash ??= bytes;\n } else {\n fields.descriptionHash ??= bytes;\n }\n }\n if (!fields.paymentHash) {\n return undefined;\n }\n return fields;\n } catch {\n return undefined;\n }\n}\n\n/** Parse the `description` tag of a kind 9735 receipt as a signed kind 9734. Never throws. */\nexport function parseZapRequestFromReceipt(receipt: Event): Event | undefined {\n try {\n const raw = firstTagValue(receipt.tags, \"description\");\n if (raw === undefined) {\n return undefined;\n }\n const parsed: unknown = JSON.parse(raw);\n if (!validateSignedEvent(parsed) || parsed.kind !== Kind.ZapRequest) {\n return undefined;\n }\n if (!verifyEvent(parsed)) {\n return undefined;\n }\n return parsed;\n } catch {\n return undefined;\n }\n}\n\n/** Validate a kind 9735 zap receipt. Never throws. */\nexport function validateZapReceipt(receipt: Event, ctx: ZapReceiptContext): ZapReceiptValidation {\n try {\n if (receipt.kind !== Kind.Zap || !verifyEvent(receipt)) {\n return fail(\"invalid receipt\");\n }\n if (receipt.pubkey.toLowerCase() !== ctx.nostrPubkey.toLowerCase()) {\n return fail(\"pubkey mismatch\");\n }\n\n const descriptionRaw = firstTagValue(receipt.tags, \"description\");\n const request = parseZapRequestFromReceipt(receipt);\n if (descriptionRaw === undefined || !request) {\n return fail(\"invalid zap request\");\n }\n if (countTags(request.tags, \"p\") !== 1) {\n return fail(\"invalid p count\");\n }\n if (countTags(request.tags, \"e\") > 1) {\n return fail(\"too many e tags\");\n }\n const relaysValue = firstTagValue(request.tags, \"relays\");\n if (relaysValue === undefined || relaysValue === \"\") {\n return fail(\"missing relays\");\n }\n\n for (const tag of request.tags) {\n if (tag[0] !== \"a\") {\n continue;\n }\n if (tag[1] === undefined) {\n return fail(\"invalid a\");\n }\n const parsed = parseEventAddress(tag[1]);\n if (!parsed || !isAddressableKind(parsed.kind)) {\n return fail(\"invalid a\");\n }\n }\n\n // Request P is the LNURL provider (receipt pubkey), not the zap sender.\n const requestPCount = countTags(request.tags, \"P\");\n if (requestPCount > 1) {\n return fail(\"too many P tags\");\n }\n if (requestPCount === 1) {\n const requestP = firstTagValue(request.tags, \"P\");\n if (requestP === undefined || requestP.toLowerCase() !== receipt.pubkey.toLowerCase()) {\n return fail(\"request P mismatch\");\n }\n }\n\n const bolt11Tag = firstTagValue(receipt.tags, \"bolt11\");\n if (bolt11Tag === undefined) {\n return fail(\"missing bolt11\");\n }\n const bolt11 = parseBolt11(bolt11Tag);\n if (!bolt11 || !bolt11.descriptionHash) {\n return fail(\"invalid bolt11\");\n }\n\n const requestAmount = firstTagValue(request.tags, \"amount\");\n if (requestAmount !== undefined) {\n const msats = parseMsatsTag(requestAmount);\n if (msats === undefined || bolt11.amountMsats !== msats) {\n return fail(\"amount mismatch\");\n }\n }\n\n // Hash the tag payload, not JSON.stringify(parsed) (key order may differ).\n const digest = sha256(utf8Encoder.encode(descriptionRaw));\n if (!equalBytes(digest, bolt11.descriptionHash)) {\n return fail(\"description hash mismatch\");\n }\n\n const requestLnurl = firstTagValue(request.tags, \"lnurl\");\n if (\n requestLnurl !== undefined &&\n ctx.lnurl !== undefined &&\n requestLnurl.toLowerCase() !== ctx.lnurl.toLowerCase()\n ) {\n return fail(\"lnurl mismatch\");\n }\n\n const preimageHex = firstTagValue(receipt.tags, \"preimage\");\n if (preimageHex !== undefined && bolt11.paymentHash) {\n let preimage: Uint8Array;\n try {\n preimage = hexToBytes(preimageHex);\n } catch {\n return fail(\"preimage mismatch\");\n }\n if (!equalBytes(sha256(preimage), bolt11.paymentHash)) {\n return fail(\"preimage mismatch\");\n }\n }\n\n const recipient = firstTagValue(request.tags, \"p\");\n if (recipient === undefined || !hasHexTagValue(receipt.tags, \"p\", recipient)) {\n return fail(\"missing p\");\n }\n const requestE = firstTagValue(request.tags, \"e\");\n if (requestE !== undefined && !hasHexTagValue(receipt.tags, \"e\", requestE)) {\n return fail(\"missing e\");\n }\n for (const tag of request.tags) {\n if (tag[0] !== \"a\" || tag[1] === undefined) {\n continue;\n }\n if (!hasAddressTag(receipt.tags, tag[1])) {\n return fail(\"missing a\");\n }\n }\n\n // Receipt P is the zap sender (request pubkey). Do not copy request tag P.\n for (const tag of receipt.tags) {\n if (tag[0] !== \"P\") {\n continue;\n }\n if (tag[1] === undefined || tag[1].toLowerCase() !== request.pubkey.toLowerCase()) {\n return fail(\"receipt P mismatch\");\n }\n }\n\n const amountMsats =\n bolt11.amountMsats ??\n (requestAmount === undefined ? undefined : parseMsatsTag(requestAmount));\n return amountMsats === undefined\n ? { valid: true, request }\n : { valid: true, request, amountMsats };\n } catch {\n return fail(\"invalid receipt\");\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"nip57.mjs","names":[],"sources":["../../src/nips/nip57.ts"],"sourcesContent":["/**\n * NIP-57 Lightning Zaps — zap request template (kind 9734) and receipt validation (kind 9735). Does\n * not fetch LNURL. Receipt checks never throw.\n *\n * @see https://github.com/nostr-protocol/nips/blob/master/57.md\n */\nimport { equalBytes } from \"@noble/ciphers/utils.js\";\nimport { sha256 } from \"@noble/hashes/sha2.js\";\nimport { bech32 } from \"@scure/base\";\n\nimport { EventValidationError } from \"../core/error.ts\";\nimport { validateSignedEvent } from \"../core/event.ts\";\nimport type { Event, EventTemplate } from \"../core/event.ts\";\nimport { isAddressableKind, Kind } from \"../core/kind.ts\";\nimport { eventAddress, getDTag, parseEventAddress, Tag } from \"../core/tag.ts\";\nimport { hexToBytes, utf8Encoder } from \"../core/util.ts\";\nimport { verifyEvent } from \"../core/verifier.ts\";\n\nexport type ProfileZapRequest = {\n pubkey: string;\n /** Amount in millisats. */\n amount: number;\n relays: ReadonlyArray<string>;\n comment?: string;\n lnurl?: string;\n};\n\nexport type EventZapRequest = {\n event: Event;\n /** Amount in millisats. */\n amount: number;\n relays: ReadonlyArray<string>;\n comment?: string;\n lnurl?: string;\n};\n\nexport function makeZapRequest(params: ProfileZapRequest | EventZapRequest): EventTemplate {\n if (params.relays.length === 0) {\n throw new EventValidationError(\"relays tag requires one or more URLs\");\n }\n\n const recipient = \"event\" in params ? params.event.pubkey : params.pubkey;\n const tags: Tag[] = [\n Tag.p(recipient),\n [\"amount\", params.amount.toString()],\n [\"relays\", ...params.relays],\n ];\n\n if (\"event\" in params) {\n const { event } = params;\n tags.push(Tag.e(event.id));\n if (isAddressableKind(event.kind)) {\n const d = getDTag(event.tags);\n if (d === undefined) {\n throw new EventValidationError(\"d tag not found\");\n }\n const addr = eventAddress(event);\n if (addr !== undefined) {\n tags.push([\"a\", addr]);\n }\n }\n tags.push([\"k\", event.kind.toString()]);\n }\n\n if (params.lnurl !== undefined && params.lnurl !== \"\") {\n tags.push([\"lnurl\", params.lnurl]);\n }\n\n return {\n kind: Kind.ZapRequest,\n created_at: Math.floor(Date.now() / 1000),\n content: params.comment ?? \"\",\n tags,\n };\n}\n\nexport type ZapReceiptContext = {\n nostrPubkey: string;\n lnurl?: string;\n};\n\nexport type ZapReceiptValidation = {\n valid: boolean;\n reason?: string;\n request?: Event;\n amountMsats?: number;\n};\n\nexport type Bolt11Fields = {\n amountMsats?: number;\n /** The `d` tagged field, UTF-8 decoded; omitted on invalid UTF-8. */\n description?: string;\n descriptionHash?: Uint8Array;\n paymentHash?: Uint8Array;\n /** Invoice creation time (unix seconds). */\n timestamp: number;\n /** `x` tagged field — seconds after `timestamp` until expiry. Defaults to 3600 when absent. */\n expiry: number;\n};\n\nfunction firstTagValue(tags: ReadonlyArray<Tag>, name: string): string | undefined {\n for (const tag of tags) {\n if (tag[0] === name && tag[1] !== undefined) {\n return tag[1];\n }\n }\n return undefined;\n}\n\nfunction fail(reason: string): ZapReceiptValidation {\n return { valid: false, reason };\n}\n\nconst MSATS_PER_BTC = 100_000_000_000;\nconst MSATS_PER_MILLI = 100_000_000;\nconst MSATS_PER_MICRO = 100_000;\nconst MSATS_PER_NANO = 100;\n\n/** Amount is digits after `ln` + currency letters, optional m/u/n/p. Non-integer pico is omitted. */\nfunction amountMsatsFromHrp(hrp: string): number | undefined {\n if (!hrp.startsWith(\"ln\")) {\n return undefined;\n }\n let i = 2;\n while (i < hrp.length) {\n const c = hrp.codePointAt(i);\n if (c === undefined || c < 97 || c > 122) {\n break;\n }\n i++;\n }\n if (i === hrp.length) {\n return undefined;\n }\n const rest = hrp.slice(i);\n const m = /^([0-9]+)([munp])?$/.exec(rest);\n if (!m) {\n return undefined;\n }\n const n = Number(m[1]);\n if (!Number.isSafeInteger(n)) {\n return undefined;\n }\n const mul = m.at(2);\n if (mul === undefined) {\n return n * MSATS_PER_BTC;\n }\n if (mul === \"m\") {\n return n * MSATS_PER_MILLI;\n }\n if (mul === \"u\") {\n return n * MSATS_PER_MICRO;\n }\n if (mul === \"n\") {\n return n * MSATS_PER_NANO;\n }\n // p: 0.1 msat; drop amounts that are not whole millisats\n if (n % 10 !== 0) {\n return undefined;\n }\n return n / 10;\n}\n\nfunction parseMsatsTag(value: string): number | undefined {\n if (!/^[0-9]+$/.test(value)) {\n return undefined;\n }\n const n = Number(value);\n return Number.isSafeInteger(n) ? n : undefined;\n}\n\nfunction countTags(tags: ReadonlyArray<Tag>, name: string): number {\n let n = 0;\n for (const tag of tags) {\n if (tag[0] === name) {\n n++;\n }\n }\n return n;\n}\n\nfunction hasHexTagValue(tags: ReadonlyArray<Tag>, name: string, value: string): boolean {\n const needle = value.toLowerCase();\n for (const tag of tags) {\n if (tag[0] === name && tag[1] !== undefined && tag[1].toLowerCase() === needle) {\n return true;\n }\n }\n return false;\n}\n\nfunction hasAddressTag(tags: ReadonlyArray<Tag>, value: string): boolean {\n const want = parseEventAddress(value);\n if (!want) {\n return false;\n }\n for (const tag of tags) {\n if (tag[0] !== \"a\" || tag[1] === undefined) {\n continue;\n }\n const got = parseEventAddress(tag[1]);\n if (\n got &&\n got.kind === want.kind &&\n got.pubkey === want.pubkey &&\n got.identifier === want.identifier\n ) {\n return true;\n }\n }\n return false;\n}\n\nconst BOLT11_TIMESTAMP_WORDS = 7;\nconst BOLT11_SIGNATURE_WORDS = 104;\nconst BOLT11_HASH_WORDS = 52;\nconst HASH_BYTES = 32;\nconst BOLT11_TAG_PAYMENT_HASH = 1;\nconst BOLT11_TAG_EXPIRY = 6;\nconst BOLT11_TAG_DESCRIPTION = 13;\nconst BOLT11_TAG_DESCRIPTION_HASH = 23;\nconst DEFAULT_EXPIRY_SECONDS = 3600;\n\nconst utf8DecoderFatal = new TextDecoder(\"utf-8\", { fatal: true });\n\n/** Big-endian 5-bit words as a JS number (safe below 2^53). */\nfunction wordsToInt(words: ReadonlyArray<number>): number {\n let value = 0;\n for (const word of words) {\n value = value * 32 + word;\n }\n return value;\n}\n\n/** Decode a BOLT11 invoice. Never throws. Requires a 32-byte payment hash (type 1). */\nexport function parseBolt11(pr: string): Bolt11Fields | undefined {\n try {\n // Invoices exceed bech32's default 90-char limit.\n const { prefix, words } = bech32.decode(pr.toLowerCase(), false);\n if (!prefix.startsWith(\"ln\")) {\n return undefined;\n }\n // timestamp (7) + tagged fields + secp256k1 signature (104)\n if (words.length < BOLT11_TIMESTAMP_WORDS + BOLT11_SIGNATURE_WORDS) {\n return undefined;\n }\n const fields: Bolt11Fields = {\n timestamp: wordsToInt(words.slice(0, BOLT11_TIMESTAMP_WORDS)),\n expiry: DEFAULT_EXPIRY_SECONDS,\n };\n let sawExpiry = false;\n const amountMsats = amountMsatsFromHrp(prefix);\n if (amountMsats !== undefined) {\n fields.amountMsats = amountMsats;\n }\n const tlvEnd = words.length - BOLT11_SIGNATURE_WORDS;\n let i = BOLT11_TIMESTAMP_WORDS;\n while (i + 3 <= tlvEnd) {\n const type = words[i];\n const lenHigh = words[i + 1];\n const lenLow = words[i + 2];\n if (type === undefined || lenHigh === undefined || lenLow === undefined) {\n break;\n }\n const dataLen = lenHigh * 32 + lenLow;\n i += 3;\n if (i + dataLen > tlvEnd) {\n break;\n }\n const data = words.slice(i, i + dataLen);\n i += dataLen;\n if (type === BOLT11_TAG_EXPIRY) {\n if (!sawExpiry) {\n sawExpiry = true;\n fields.expiry = wordsToInt(data);\n }\n continue;\n }\n if (type === BOLT11_TAG_DESCRIPTION) {\n const bytes = bech32.fromWordsUnsafe(data);\n if (bytes !== undefined) {\n try {\n fields.description ??= utf8DecoderFatal.decode(bytes);\n } catch {\n // Invalid UTF-8 — drop the field, not the invoice.\n }\n }\n continue;\n }\n if (\n (type !== BOLT11_TAG_PAYMENT_HASH && type !== BOLT11_TAG_DESCRIPTION_HASH) ||\n dataLen !== BOLT11_HASH_WORDS\n ) {\n continue;\n }\n const bytes = bech32.fromWordsUnsafe(data);\n if (!bytes || bytes.length !== HASH_BYTES) {\n continue;\n }\n if (type === BOLT11_TAG_PAYMENT_HASH) {\n fields.paymentHash ??= bytes;\n } else {\n fields.descriptionHash ??= bytes;\n }\n }\n if (!fields.paymentHash) {\n return undefined;\n }\n return fields;\n } catch {\n return undefined;\n }\n}\n\n/** Parse the `description` tag of a kind 9735 receipt as a signed kind 9734. Never throws. */\nexport function parseZapRequestFromReceipt(receipt: Event): Event | undefined {\n try {\n const raw = firstTagValue(receipt.tags, \"description\");\n if (raw === undefined) {\n return undefined;\n }\n const parsed: unknown = JSON.parse(raw);\n if (!validateSignedEvent(parsed) || parsed.kind !== Kind.ZapRequest) {\n return undefined;\n }\n if (!verifyEvent(parsed)) {\n return undefined;\n }\n return parsed;\n } catch {\n return undefined;\n }\n}\n\n/** Validate a kind 9735 zap receipt. Never throws. */\nexport function validateZapReceipt(receipt: Event, ctx: ZapReceiptContext): ZapReceiptValidation {\n try {\n if (receipt.kind !== Kind.Zap || !verifyEvent(receipt)) {\n return fail(\"invalid receipt\");\n }\n if (receipt.pubkey.toLowerCase() !== ctx.nostrPubkey.toLowerCase()) {\n return fail(\"pubkey mismatch\");\n }\n\n const descriptionRaw = firstTagValue(receipt.tags, \"description\");\n const request = parseZapRequestFromReceipt(receipt);\n if (descriptionRaw === undefined || !request) {\n return fail(\"invalid zap request\");\n }\n if (countTags(request.tags, \"p\") !== 1) {\n return fail(\"invalid p count\");\n }\n if (countTags(request.tags, \"e\") > 1) {\n return fail(\"too many e tags\");\n }\n const relaysValue = firstTagValue(request.tags, \"relays\");\n if (relaysValue === undefined || relaysValue === \"\") {\n return fail(\"missing relays\");\n }\n\n for (const tag of request.tags) {\n if (tag[0] !== \"a\") {\n continue;\n }\n if (tag[1] === undefined) {\n return fail(\"invalid a\");\n }\n const parsed = parseEventAddress(tag[1]);\n if (!parsed || !isAddressableKind(parsed.kind)) {\n return fail(\"invalid a\");\n }\n }\n\n // Request P is the LNURL provider (receipt pubkey), not the zap sender.\n const requestPCount = countTags(request.tags, \"P\");\n if (requestPCount > 1) {\n return fail(\"too many P tags\");\n }\n if (requestPCount === 1) {\n const requestP = firstTagValue(request.tags, \"P\");\n if (requestP === undefined || requestP.toLowerCase() !== receipt.pubkey.toLowerCase()) {\n return fail(\"request P mismatch\");\n }\n }\n\n const bolt11Tag = firstTagValue(receipt.tags, \"bolt11\");\n if (bolt11Tag === undefined) {\n return fail(\"missing bolt11\");\n }\n const bolt11 = parseBolt11(bolt11Tag);\n if (!bolt11 || !bolt11.descriptionHash) {\n return fail(\"invalid bolt11\");\n }\n\n const requestAmount = firstTagValue(request.tags, \"amount\");\n if (requestAmount !== undefined) {\n const msats = parseMsatsTag(requestAmount);\n if (msats === undefined || bolt11.amountMsats !== msats) {\n return fail(\"amount mismatch\");\n }\n }\n\n // Hash the tag payload, not JSON.stringify(parsed) (key order may differ).\n const digest = sha256(utf8Encoder.encode(descriptionRaw));\n if (!equalBytes(digest, bolt11.descriptionHash)) {\n return fail(\"description hash mismatch\");\n }\n\n const requestLnurl = firstTagValue(request.tags, \"lnurl\");\n if (\n requestLnurl !== undefined &&\n ctx.lnurl !== undefined &&\n requestLnurl.toLowerCase() !== ctx.lnurl.toLowerCase()\n ) {\n return fail(\"lnurl mismatch\");\n }\n\n const preimageHex = firstTagValue(receipt.tags, \"preimage\");\n if (preimageHex !== undefined && bolt11.paymentHash) {\n let preimage: Uint8Array;\n try {\n preimage = hexToBytes(preimageHex);\n } catch {\n return fail(\"preimage mismatch\");\n }\n if (!equalBytes(sha256(preimage), bolt11.paymentHash)) {\n return fail(\"preimage mismatch\");\n }\n }\n\n const recipient = firstTagValue(request.tags, \"p\");\n if (recipient === undefined || !hasHexTagValue(receipt.tags, \"p\", recipient)) {\n return fail(\"missing p\");\n }\n const requestE = firstTagValue(request.tags, \"e\");\n if (requestE !== undefined && !hasHexTagValue(receipt.tags, \"e\", requestE)) {\n return fail(\"missing e\");\n }\n for (const tag of request.tags) {\n if (tag[0] !== \"a\" || tag[1] === undefined) {\n continue;\n }\n if (!hasAddressTag(receipt.tags, tag[1])) {\n return fail(\"missing a\");\n }\n }\n\n // Receipt P is the zap sender (request pubkey). Do not copy request tag P.\n for (const tag of receipt.tags) {\n if (tag[0] !== \"P\") {\n continue;\n }\n if (tag[1] === undefined || tag[1].toLowerCase() !== request.pubkey.toLowerCase()) {\n return fail(\"receipt P mismatch\");\n }\n }\n\n const amountMsats =\n bolt11.amountMsats ??\n (requestAmount === undefined ? undefined : parseMsatsTag(requestAmount));\n return amountMsats === undefined\n ? { valid: true, request }\n : { valid: true, request, amountMsats };\n } catch {\n return fail(\"invalid receipt\");\n }\n}\n"],"mappings":"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"}
|