@qntx/nostr 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/abort-Ba8tDrOI.mjs +38 -0
- package/dist/abort-Ba8tDrOI.mjs.map +1 -0
- package/dist/{builder-BphJa-Y-.d.mts → builder-BWn55Paf.d.mts} +20 -14
- package/dist/{builder-q0VsvnYE.mjs → builder-CyrGKRVP.mjs} +32 -16
- package/dist/builder-CyrGKRVP.mjs.map +1 -0
- package/dist/{client-Bd8B1mYm.mjs → client-BWsX9Cv1.mjs} +354 -195
- package/dist/client-BWsX9Cv1.mjs.map +1 -0
- package/dist/client.d.mts +1 -1
- package/dist/client.mjs +1 -1
- package/dist/core.d.mts +8 -8
- package/dist/core.mjs +8 -9
- package/dist/error-BW7re1UN.d.mts +9 -0
- package/dist/error-B__OMBRY.mjs +14 -0
- package/dist/error-B__OMBRY.mjs.map +1 -0
- package/dist/{error-WEFS-R-4.d.mts → error-CR8Urc84.d.mts} +20 -8
- package/dist/{error-CwBxle6N.mjs → error-DrEIM7FO.mjs} +5 -3
- package/dist/error-DrEIM7FO.mjs.map +1 -0
- package/dist/{event-CzoQEFMF.mjs → event-D8Nfoh4K.mjs} +40 -32
- package/dist/event-D8Nfoh4K.mjs.map +1 -0
- package/dist/{event-B5D2jxt_.d.mts → event-fU3Y01PR.d.mts} +23 -20
- package/dist/{filter-BxwjEkZp.mjs → filter-BQl3GlHt.mjs} +42 -35
- package/dist/filter-BQl3GlHt.mjs.map +1 -0
- package/dist/filter-C0IMByE3.d.mts +37 -0
- package/dist/{gossip-C5RVB1UB.d.mts → gossip-BRQwIM_q.d.mts} +25 -23
- package/dist/{gossip-3xkRRCm_.mjs → gossip-oloDMWEj.mjs} +52 -34
- package/dist/gossip-oloDMWEj.mjs.map +1 -0
- package/dist/gossip.d.mts +1 -1
- package/dist/gossip.mjs +1 -1
- package/dist/{http-6vevbBlu.d.mts → http-C4HwIu4B.d.mts} +7 -7
- package/dist/{http-D2rhokZo.mjs → http-CuazNRIP.mjs} +10 -6
- package/dist/http-CuazNRIP.mjs.map +1 -0
- package/dist/index-B425CAcQ.d.mts +328 -0
- package/dist/index-BVyfUBuG.d.mts +181 -0
- package/dist/{index-BYMnhRIC.d.mts → index-Ba7bJ7DD.d.mts} +68 -50
- package/dist/{index-DaRyjo7O.d.mts → index-Boe8FshM.d.mts} +13 -7
- package/dist/index-CUIvFQdE.d.mts +73 -0
- package/dist/index-JdaS_uce.d.mts +207 -0
- package/dist/index.d.mts +22 -20
- package/dist/index.mjs +19 -18
- package/dist/{key-Dn-aX4x4.mjs → key-Bbtys6o7.mjs} +22 -14
- package/dist/key-Bbtys6o7.mjs.map +1 -0
- package/dist/{key-BiGxGLhg.d.mts → key-BlsYhN1V.d.mts} +9 -6
- package/dist/{kind-BLl4cLOc.mjs → kind-Cxwhhkx8.mjs} +2 -1
- package/dist/kind-Cxwhhkx8.mjs.map +1 -0
- package/dist/{loaders-BNQszDAn.mjs → loaders-o6YXB_Ri.mjs} +329 -305
- package/dist/loaders-o6YXB_Ri.mjs.map +1 -0
- package/dist/loaders.d.mts +3 -3
- package/dist/loaders.mjs +2 -2
- package/dist/memory-C2EVx0_O.mjs +46 -0
- package/dist/memory-C2EVx0_O.mjs.map +1 -0
- package/dist/{memory-BCmhZPek.mjs → memory-index-Do1mz0WF.mjs} +212 -82
- package/dist/memory-index-Do1mz0WF.mjs.map +1 -0
- package/dist/{message-C_Uj-Y-c.mjs → message-DcY6Fi9o.mjs} +38 -24
- package/dist/message-DcY6Fi9o.mjs.map +1 -0
- package/dist/{message-DJC-Jz6T.d.mts → message-iGPIqnn0.d.mts} +15 -8
- package/dist/{nip17-BWq-Kfq5.d.mts → nip17-DNEkz8rM.d.mts} +18 -17
- package/dist/{nip19-D7905oIv.d.mts → nip19-BevCJZsp.d.mts} +23 -3
- package/dist/{nip46-C_DoxiS_.d.mts → nip46-DDV4dC56.d.mts} +13 -12
- package/dist/{nip59-DljW_sYh.d.mts → nip59-zQgdLcEr.d.mts} +28 -25
- package/dist/{nip65-Di5hZRuV.d.mts → nip65-DBfh2smt.d.mts} +8 -3
- package/dist/{nip77-ByxlgoQO.d.mts → nip77-DH4RhS5r.d.mts} +9 -8
- package/dist/nips/blossom.d.mts +37 -37
- package/dist/nips/blossom.mjs +67 -52
- package/dist/nips/blossom.mjs.map +1 -1
- package/dist/nips/nip04.d.mts +2 -3
- package/dist/nips/nip04.mjs +6 -10
- package/dist/nips/nip04.mjs.map +1 -1
- package/dist/nips/nip05.d.mts +37 -43
- package/dist/nips/nip05.mjs +57 -57
- package/dist/nips/nip05.mjs.map +1 -1
- package/dist/nips/nip10.d.mts +18 -19
- package/dist/nips/nip10.mjs +76 -61
- package/dist/nips/nip10.mjs.map +1 -1
- package/dist/nips/nip11.d.mts +9 -8
- package/dist/nips/nip11.mjs +14 -11
- package/dist/nips/nip11.mjs.map +1 -1
- package/dist/nips/nip13.d.mts +9 -9
- package/dist/nips/nip13.mjs +15 -17
- package/dist/nips/nip13.mjs.map +1 -1
- package/dist/nips/nip17.d.mts +1 -1
- package/dist/nips/nip17.mjs +17 -12
- package/dist/nips/nip17.mjs.map +1 -1
- package/dist/nips/nip19.d.mts +1 -1
- package/dist/nips/nip19.mjs +52 -29
- package/dist/nips/nip19.mjs.map +1 -1
- package/dist/nips/nip21.d.mts +5 -6
- package/dist/nips/nip21.mjs +8 -7
- package/dist/nips/nip21.mjs.map +1 -1
- package/dist/nips/nip27.d.mts +7 -8
- package/dist/nips/nip27.mjs +26 -25
- package/dist/nips/nip27.mjs.map +1 -1
- package/dist/nips/nip42.d.mts +5 -6
- package/dist/nips/nip42.mjs +3 -3
- package/dist/nips/nip42.mjs.map +1 -1
- package/dist/nips/nip44.d.mts +24 -7
- package/dist/nips/nip44.mjs +25 -15
- package/dist/nips/nip44.mjs.map +1 -1
- package/dist/nips/nip46.d.mts +1 -1
- package/dist/nips/nip46.mjs +52 -49
- package/dist/nips/nip46.mjs.map +1 -1
- package/dist/nips/nip49.d.mts +8 -7
- package/dist/nips/nip49.mjs +16 -12
- package/dist/nips/nip49.mjs.map +1 -1
- package/dist/nips/nip51.d.mts +25 -27
- package/dist/nips/nip51.mjs +47 -36
- package/dist/nips/nip51.mjs.map +1 -1
- package/dist/nips/nip57.d.mts +12 -13
- package/dist/nips/nip57.mjs +35 -38
- package/dist/nips/nip57.mjs.map +1 -1
- package/dist/nips/nip59.d.mts +1 -1
- package/dist/nips/nip59.mjs +14 -18
- package/dist/nips/nip59.mjs.map +1 -1
- package/dist/nips/nip65.d.mts +1 -1
- package/dist/nips/nip65.mjs +11 -8
- package/dist/nips/nip65.mjs.map +1 -1
- package/dist/nips/nip77.d.mts +1 -1
- package/dist/nips/nip77.mjs +56 -34
- package/dist/nips/nip77.mjs.map +1 -1
- package/dist/nips/nip96.d.mts +21 -12
- package/dist/nips/nip96.mjs +59 -36
- package/dist/nips/nip96.mjs.map +1 -1
- package/dist/nips/nip98.d.mts +12 -12
- package/dist/nips/nip98.mjs +18 -19
- package/dist/nips/nip98.mjs.map +1 -1
- package/dist/{nostr_crypto_wasm_bg-hi5aXi5P.mjs → nostr_crypto_wasm_bg-Czrdty7Q.mjs} +1 -1
- package/dist/nostr_crypto_wasm_bg.wasm +0 -0
- package/dist/{pool-C_j1OmBB.mjs → pool-XmAGaufS.mjs} +611 -487
- package/dist/pool-XmAGaufS.mjs.map +1 -0
- package/dist/pool-tEr7c5bG.d.mts +302 -0
- package/dist/reactive-DDlThDSZ.mjs +324 -0
- package/dist/reactive-DDlThDSZ.mjs.map +1 -0
- package/dist/reactive-DFpqtIf_.d.mts +66 -0
- package/dist/relay.d.mts +5 -4
- package/dist/relay.mjs +2 -2
- package/dist/report-CHld4uWj.mjs +32 -0
- package/dist/report-CHld4uWj.mjs.map +1 -0
- package/dist/{signer-CZ6K6FUc.mjs → signer-CKR9EHce.mjs} +107 -72
- package/dist/signer-CKR9EHce.mjs.map +1 -0
- package/dist/signer.d.mts +3 -3
- package/dist/signer.mjs +3 -2
- package/dist/storage-xXxLGCVb.mjs +1376 -0
- package/dist/storage-xXxLGCVb.mjs.map +1 -0
- package/dist/storage.d.mts +4 -4
- package/dist/storage.mjs +5 -4
- package/dist/store.d.mts +2 -0
- package/dist/store.mjs +2 -0
- package/dist/testing.d.mts +125 -0
- package/dist/testing.mjs +957 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/types-2pCUprRU.d.mts +47 -0
- package/dist/types-CRQEk0Oi.d.mts +19 -0
- package/dist/util-LfQirgDk.mjs +113 -0
- package/dist/util-LfQirgDk.mjs.map +1 -0
- package/dist/wasm.d.mts +4 -4
- package/dist/wasm.mjs +55 -56
- package/dist/wasm.mjs.map +1 -1
- package/dist/websocket-CmeqvAkp.d.mts +27 -0
- package/package.json +31 -9
- package/dist/builder-q0VsvnYE.mjs.map +0 -1
- package/dist/client-Bd8B1mYm.mjs.map +0 -1
- package/dist/error-B49rbDzT.d.mts +0 -7
- package/dist/error-CwBxle6N.mjs.map +0 -1
- package/dist/error-DZEs5OuZ.mjs +0 -24
- package/dist/error-DZEs5OuZ.mjs.map +0 -1
- package/dist/event-CzoQEFMF.mjs.map +0 -1
- package/dist/filter-BxwjEkZp.mjs.map +0 -1
- package/dist/filter-DUEKP3q1.d.mts +0 -40
- package/dist/gossip-3xkRRCm_.mjs.map +0 -1
- package/dist/http-D2rhokZo.mjs.map +0 -1
- package/dist/index-C0C4e-Gn.d.mts +0 -55
- package/dist/index-DgOugrpF.d.mts +0 -275
- package/dist/index-EwL6fHLo.d.mts +0 -267
- package/dist/index-k2YhCgsh.d.mts +0 -57
- package/dist/key-Dn-aX4x4.mjs.map +0 -1
- package/dist/kind-BLl4cLOc.mjs.map +0 -1
- package/dist/loaders-BNQszDAn.mjs.map +0 -1
- package/dist/memory-BCmhZPek.mjs.map +0 -1
- package/dist/message-C_Uj-Y-c.mjs.map +0 -1
- package/dist/pool-C_j1OmBB.mjs.map +0 -1
- package/dist/pool-DhRi7l-b.d.mts +0 -297
- package/dist/signer-CZ6K6FUc.mjs.map +0 -1
- package/dist/storage-DOy0iLRE.mjs +0 -870
- package/dist/storage-DOy0iLRE.mjs.map +0 -1
- package/dist/types-BzyADqnw.d.mts +0 -19
- package/dist/types-Dn24TPFs.d.mts +0 -43
- package/dist/util-6tfLQ9JF.mjs +0 -73
- package/dist/util-6tfLQ9JF.mjs.map +0 -1
package/dist/testing.mjs
ADDED
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
import { c as isRecord, i as bytesToHex, l as normalizeURL } from "./util-LfQirgDk.mjs";
|
|
2
|
+
import { t as Kind } from "./kind-Cxwhhkx8.mjs";
|
|
3
|
+
import { m as validateSignedEvent, t as compareEventsDesc, y as isTag } from "./event-D8Nfoh4K.mjs";
|
|
4
|
+
import { r as finalizeEvent, s as verifyEvent, t as Keys } from "./key-Bbtys6o7.mjs";
|
|
5
|
+
import { t as EventBuilder } from "./builder-CyrGKRVP.mjs";
|
|
6
|
+
import { a as matchFilter } from "./filter-BQl3GlHt.mjs";
|
|
7
|
+
import { decrypt, encrypt, getConversationKey } from "./nips/nip44.mjs";
|
|
8
|
+
import { decodeNip46Request, encodeNip46Response } from "./nips/nip46.mjs";
|
|
9
|
+
import { Negentropy, storageFromEvents } from "./nips/nip77.mjs";
|
|
10
|
+
import { t as MemoryEventStore } from "./memory-C2EVx0_O.mjs";
|
|
11
|
+
//#region src/testing/relay-core.ts
|
|
12
|
+
/** Wire filters are trusted as NIP-01 filters once they are records. */
|
|
13
|
+
function isFilter(value) {
|
|
14
|
+
return isRecord(value);
|
|
15
|
+
}
|
|
16
|
+
function searchMatch(filter, event) {
|
|
17
|
+
if (filter.search === void 0) return true;
|
|
18
|
+
return event.content.toLowerCase().includes(filter.search.toLowerCase());
|
|
19
|
+
}
|
|
20
|
+
function subMatches(filters, event) {
|
|
21
|
+
return filters.some((f) => matchFilter(f, event) && searchMatch(f, event));
|
|
22
|
+
}
|
|
23
|
+
/** Put outcomes that made the event part of the relay's live stream. */
|
|
24
|
+
function isLivePut(result) {
|
|
25
|
+
return result === "accepted" || result === "replaced" || result === "deleted" || result === "ephemeral";
|
|
26
|
+
}
|
|
27
|
+
function mayReadKinds(filters, readKinds) {
|
|
28
|
+
return filters.some((f) => f.kinds === void 0 || f.kinds.some((k) => readKinds.includes(k)));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Transport-agnostic fake relay: one NIP-01/42/45/50/77 session state machine per connection,
|
|
32
|
+
* storage via {@link MemoryEventStore}. No timers except `latencyMs`; no global state.
|
|
33
|
+
*/
|
|
34
|
+
var FakeRelayCore = class {
|
|
35
|
+
url;
|
|
36
|
+
#opts;
|
|
37
|
+
#store = new MemoryEventStore();
|
|
38
|
+
#sessions = /* @__PURE__ */ new Set();
|
|
39
|
+
#mirror = /* @__PURE__ */ new Map();
|
|
40
|
+
#inbox = [];
|
|
41
|
+
#pending = Promise.resolve();
|
|
42
|
+
constructor(url, opts = {}) {
|
|
43
|
+
this.url = normalizeURL(url);
|
|
44
|
+
this.#opts = opts;
|
|
45
|
+
}
|
|
46
|
+
configure(opts) {
|
|
47
|
+
this.#opts = {
|
|
48
|
+
...this.#opts,
|
|
49
|
+
...opts
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
seed(events) {
|
|
53
|
+
for (const event of events) this.#mirror.set(event.id, event);
|
|
54
|
+
this.#pending = (async () => {
|
|
55
|
+
await this.#pending;
|
|
56
|
+
for (const event of events) await this.#store.put(event);
|
|
57
|
+
await this.#refreshMirror();
|
|
58
|
+
})();
|
|
59
|
+
}
|
|
60
|
+
events() {
|
|
61
|
+
return [...this.#mirror.values()];
|
|
62
|
+
}
|
|
63
|
+
inject(event) {
|
|
64
|
+
this.#pending = (async () => {
|
|
65
|
+
await this.#pending;
|
|
66
|
+
const result = await this.#store.put(event);
|
|
67
|
+
await this.#refreshMirror();
|
|
68
|
+
if (isLivePut(result)) this.#deliver(event);
|
|
69
|
+
})();
|
|
70
|
+
}
|
|
71
|
+
disconnect() {
|
|
72
|
+
const sessions = [...this.#sessions];
|
|
73
|
+
this.#sessions.clear();
|
|
74
|
+
for (const session of sessions) session.transport.close();
|
|
75
|
+
}
|
|
76
|
+
closeSubscriptions(reason) {
|
|
77
|
+
for (const session of this.#sessions) {
|
|
78
|
+
for (const id of session.subs.keys()) this.#send(session, [
|
|
79
|
+
"CLOSED",
|
|
80
|
+
id,
|
|
81
|
+
reason
|
|
82
|
+
]);
|
|
83
|
+
session.subs.clear();
|
|
84
|
+
session.negs.clear();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
clientMessages() {
|
|
88
|
+
return this.#inbox;
|
|
89
|
+
}
|
|
90
|
+
/** Attach a transport; returns a handle for {@link handleMessage}/detach. */
|
|
91
|
+
connect(transport) {
|
|
92
|
+
const session = {
|
|
93
|
+
transport,
|
|
94
|
+
subs: /* @__PURE__ */ new Map(),
|
|
95
|
+
negs: /* @__PURE__ */ new Map(),
|
|
96
|
+
authed: false,
|
|
97
|
+
queue: Promise.resolve()
|
|
98
|
+
};
|
|
99
|
+
this.#sessions.add(session);
|
|
100
|
+
if (this.#opts.auth) this.#send(session, ["AUTH", this.#opts.auth.challenge]);
|
|
101
|
+
return session;
|
|
102
|
+
}
|
|
103
|
+
detach(session) {
|
|
104
|
+
this.#sessions.delete(session);
|
|
105
|
+
}
|
|
106
|
+
/** Feed a raw client→relay frame. Messages of one session are handled in order. */
|
|
107
|
+
handleMessage(session, raw) {
|
|
108
|
+
session.queue = (async () => {
|
|
109
|
+
try {
|
|
110
|
+
await session.queue;
|
|
111
|
+
await this.#handle(session, raw);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
114
|
+
try {
|
|
115
|
+
this.#send(session, ["NOTICE", `error: ${message}`]);
|
|
116
|
+
} catch {}
|
|
117
|
+
}
|
|
118
|
+
})();
|
|
119
|
+
}
|
|
120
|
+
async #refreshMirror() {
|
|
121
|
+
const all = await this.#store.query([{}]);
|
|
122
|
+
this.#mirror = new Map(all.map((e) => [e.id, e]));
|
|
123
|
+
}
|
|
124
|
+
#send(session, message) {
|
|
125
|
+
const payload = JSON.stringify(message);
|
|
126
|
+
const { latencyMs } = this.#opts;
|
|
127
|
+
if (latencyMs !== void 0 && latencyMs > 0) {
|
|
128
|
+
setTimeout(() => session.transport.send(payload), latencyMs);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
session.transport.send(payload);
|
|
132
|
+
}
|
|
133
|
+
#deliver(event) {
|
|
134
|
+
for (const session of this.#sessions) for (const [id, filters] of session.subs) if (subMatches(filters, event)) this.#send(session, [
|
|
135
|
+
"EVENT",
|
|
136
|
+
id,
|
|
137
|
+
event
|
|
138
|
+
]);
|
|
139
|
+
}
|
|
140
|
+
async #matched(filters) {
|
|
141
|
+
const keptPerFilter = await Promise.all(filters.map(async (filter) => {
|
|
142
|
+
const unbounded = { ...filter };
|
|
143
|
+
delete unbounded.limit;
|
|
144
|
+
const rows = (await this.#store.query([unbounded])).filter((event) => searchMatch(filter, event));
|
|
145
|
+
rows.sort(compareEventsDesc);
|
|
146
|
+
return filter.limit === void 0 ? rows : rows.slice(0, filter.limit);
|
|
147
|
+
}));
|
|
148
|
+
const seen = /* @__PURE__ */ new Map();
|
|
149
|
+
for (const kept of keptPerFilter) for (const event of kept) if (!seen.has(event.id)) seen.set(event.id, event);
|
|
150
|
+
const matched = [...seen.values()];
|
|
151
|
+
matched.sort(compareEventsDesc);
|
|
152
|
+
return matched;
|
|
153
|
+
}
|
|
154
|
+
async #handle(session, raw) {
|
|
155
|
+
await this.#pending;
|
|
156
|
+
let msg;
|
|
157
|
+
try {
|
|
158
|
+
msg = JSON.parse(raw);
|
|
159
|
+
} catch {
|
|
160
|
+
this.#send(session, ["NOTICE", "error: invalid message"]);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (!Array.isArray(msg)) {
|
|
164
|
+
this.#send(session, ["NOTICE", "error: invalid message"]);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const items = msg;
|
|
168
|
+
const [type] = items;
|
|
169
|
+
if (typeof type !== "string") {
|
|
170
|
+
this.#send(session, ["NOTICE", "error: invalid message"]);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
this.#inbox.push(msg);
|
|
174
|
+
switch (type) {
|
|
175
|
+
case "EVENT": {
|
|
176
|
+
const [, rawEvent] = items;
|
|
177
|
+
if (!isRecord(rawEvent) || typeof rawEvent["id"] !== "string") {
|
|
178
|
+
this.#send(session, ["NOTICE", "invalid: malformed event"]);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const event = rawEvent;
|
|
182
|
+
if (this.#opts.rateLimited === true) {
|
|
183
|
+
this.#send(session, [
|
|
184
|
+
"OK",
|
|
185
|
+
event.id,
|
|
186
|
+
false,
|
|
187
|
+
"rate-limited: slow down"
|
|
188
|
+
]);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (this.#opts.auth?.writes === true && !session.authed) {
|
|
192
|
+
this.#send(session, [
|
|
193
|
+
"OK",
|
|
194
|
+
event.id,
|
|
195
|
+
false,
|
|
196
|
+
"auth-required: login first"
|
|
197
|
+
]);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (!verifyEvent(event)) {
|
|
201
|
+
this.#send(session, [
|
|
202
|
+
"OK",
|
|
203
|
+
event.id,
|
|
204
|
+
false,
|
|
205
|
+
"invalid: signature or id"
|
|
206
|
+
]);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const result = await this.#store.put(event);
|
|
210
|
+
await this.#refreshMirror();
|
|
211
|
+
if (result === "rejected") {
|
|
212
|
+
this.#send(session, [
|
|
213
|
+
"OK",
|
|
214
|
+
event.id,
|
|
215
|
+
false,
|
|
216
|
+
"invalid: a newer version of this event exists"
|
|
217
|
+
]);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (result === "invalid") {
|
|
221
|
+
this.#send(session, [
|
|
222
|
+
"OK",
|
|
223
|
+
event.id,
|
|
224
|
+
false,
|
|
225
|
+
"invalid: malformed event"
|
|
226
|
+
]);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (result === "duplicate") {
|
|
230
|
+
this.#send(session, [
|
|
231
|
+
"OK",
|
|
232
|
+
event.id,
|
|
233
|
+
true,
|
|
234
|
+
"duplicate: already have this event"
|
|
235
|
+
]);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
this.#send(session, [
|
|
239
|
+
"OK",
|
|
240
|
+
event.id,
|
|
241
|
+
true,
|
|
242
|
+
""
|
|
243
|
+
]);
|
|
244
|
+
this.#deliver(event);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
case "REQ": {
|
|
248
|
+
const [, id] = items;
|
|
249
|
+
const filters = items.slice(2);
|
|
250
|
+
if (typeof id !== "string" || filters.length === 0 || !filters.every(isFilter)) {
|
|
251
|
+
this.#send(session, ["NOTICE", "error: invalid REQ"]);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const readKinds = this.#opts.auth?.readKinds;
|
|
255
|
+
if (readKinds !== void 0 && !session.authed && mayReadKinds(filters, readKinds)) {
|
|
256
|
+
this.#send(session, [
|
|
257
|
+
"CLOSED",
|
|
258
|
+
id,
|
|
259
|
+
"auth-required: we only serve to authed users"
|
|
260
|
+
]);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
session.subs.set(id, filters);
|
|
264
|
+
const matched = await this.#matched(filters);
|
|
265
|
+
if (this.#opts.eoseBeforeEvents === true) this.#send(session, ["EOSE", id]);
|
|
266
|
+
for (const event of matched) this.#send(session, [
|
|
267
|
+
"EVENT",
|
|
268
|
+
id,
|
|
269
|
+
event
|
|
270
|
+
]);
|
|
271
|
+
if (this.#opts.eoseBeforeEvents !== true) this.#send(session, ["EOSE", id]);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
case "CLOSE": {
|
|
275
|
+
const [, id] = items;
|
|
276
|
+
if (typeof id === "string") session.subs.delete(id);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
case "COUNT": {
|
|
280
|
+
const [, id] = items;
|
|
281
|
+
const filters = items.slice(2);
|
|
282
|
+
if (typeof id !== "string" || !filters.every(isFilter)) {
|
|
283
|
+
this.#send(session, ["NOTICE", "error: invalid COUNT"]);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const matched = await this.#matched(filters);
|
|
287
|
+
this.#send(session, [
|
|
288
|
+
"COUNT",
|
|
289
|
+
id,
|
|
290
|
+
{ count: matched.length }
|
|
291
|
+
]);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
case "AUTH": {
|
|
295
|
+
const { auth } = this.#opts;
|
|
296
|
+
const [, rawAuth] = items;
|
|
297
|
+
if (!validateSignedEvent(rawAuth)) {
|
|
298
|
+
this.#send(session, [
|
|
299
|
+
"OK",
|
|
300
|
+
"0".repeat(64),
|
|
301
|
+
false,
|
|
302
|
+
"error: invalid auth event"
|
|
303
|
+
]);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const challengeTag = rawAuth.tags.find((t) => t[0] === "challenge")?.[1];
|
|
307
|
+
const relayTag = rawAuth.tags.find((t) => t[0] === "relay")?.[1];
|
|
308
|
+
let relayOk = false;
|
|
309
|
+
if (relayTag !== void 0) try {
|
|
310
|
+
relayOk = normalizeURL(relayTag) === this.url;
|
|
311
|
+
} catch {}
|
|
312
|
+
if (auth !== void 0 && rawAuth.kind === Kind.ClientAuth && verifyEvent(rawAuth) && challengeTag === auth.challenge && relayOk) {
|
|
313
|
+
session.authed = true;
|
|
314
|
+
this.#send(session, [
|
|
315
|
+
"OK",
|
|
316
|
+
rawAuth.id,
|
|
317
|
+
true,
|
|
318
|
+
""
|
|
319
|
+
]);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
this.#send(session, [
|
|
323
|
+
"OK",
|
|
324
|
+
rawAuth.id,
|
|
325
|
+
false,
|
|
326
|
+
"error: invalid auth event"
|
|
327
|
+
]);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
case "NEG-OPEN": {
|
|
331
|
+
if (items.length === 5) {
|
|
332
|
+
this.#send(session, [
|
|
333
|
+
"NEG-ERR",
|
|
334
|
+
items[1],
|
|
335
|
+
"error: obsolete 5-element NEG-OPEN"
|
|
336
|
+
]);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const [, id, rawFilter, negMessage] = items;
|
|
340
|
+
if (items.length !== 4 || typeof id !== "string" || !isFilter(rawFilter) || typeof negMessage !== "string") {
|
|
341
|
+
const badId = typeof id === "string" ? id : "";
|
|
342
|
+
this.#send(session, [
|
|
343
|
+
"NEG-ERR",
|
|
344
|
+
badId,
|
|
345
|
+
"error: invalid NEG-OPEN"
|
|
346
|
+
]);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const matched = await this.#matched([rawFilter]);
|
|
350
|
+
const neg = new Negentropy(storageFromEvents(matched));
|
|
351
|
+
let opened;
|
|
352
|
+
try {
|
|
353
|
+
opened = neg.reconcile(negMessage).nextMessage ?? void 0;
|
|
354
|
+
} catch {
|
|
355
|
+
this.#send(session, [
|
|
356
|
+
"NEG-ERR",
|
|
357
|
+
id,
|
|
358
|
+
"error: invalid negentropy message"
|
|
359
|
+
]);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
session.negs.set(id, neg);
|
|
363
|
+
this.#send(session, [
|
|
364
|
+
"NEG-MSG",
|
|
365
|
+
id,
|
|
366
|
+
opened ?? bytesToHex(new Uint8Array([97]))
|
|
367
|
+
]);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
case "NEG-MSG": {
|
|
371
|
+
const [, id, negMessage] = items;
|
|
372
|
+
if (items.length !== 3 || typeof id !== "string" || typeof negMessage !== "string") return;
|
|
373
|
+
const handle = session.negs.get(id);
|
|
374
|
+
if (handle === void 0) {
|
|
375
|
+
this.#send(session, [
|
|
376
|
+
"NEG-ERR",
|
|
377
|
+
id,
|
|
378
|
+
"closed: unknown subscription"
|
|
379
|
+
]);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
let next;
|
|
383
|
+
try {
|
|
384
|
+
next = handle.reconcile(negMessage).nextMessage ?? void 0;
|
|
385
|
+
} catch {
|
|
386
|
+
session.negs.delete(id);
|
|
387
|
+
this.#send(session, [
|
|
388
|
+
"NEG-ERR",
|
|
389
|
+
id,
|
|
390
|
+
"error: invalid negentropy message"
|
|
391
|
+
]);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
this.#send(session, [
|
|
395
|
+
"NEG-MSG",
|
|
396
|
+
id,
|
|
397
|
+
next ?? bytesToHex(new Uint8Array([97]))
|
|
398
|
+
]);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
case "NEG-CLOSE": {
|
|
402
|
+
const [, id] = items;
|
|
403
|
+
if (typeof id === "string") session.negs.delete(id);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
default: this.#send(session, ["NOTICE", `error: unsupported ${type}`]);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
//#endregion
|
|
411
|
+
//#region src/testing/network.ts
|
|
412
|
+
/**
|
|
413
|
+
* In-process fake relay network. `new websocketImplementation(url)` opens a socket bound to the
|
|
414
|
+
* network's relay for that URL; `send` is dispatched to the relay session directly (no polling).
|
|
415
|
+
*/
|
|
416
|
+
function createFakeRelayNetwork(defaults = {}) {
|
|
417
|
+
const cores = /* @__PURE__ */ new Map();
|
|
418
|
+
const coreFor = (url, opts) => {
|
|
419
|
+
const key = normalizeURL(url);
|
|
420
|
+
let core = cores.get(key);
|
|
421
|
+
if (!core) {
|
|
422
|
+
core = new FakeRelayCore(key, {
|
|
423
|
+
...defaults,
|
|
424
|
+
...opts
|
|
425
|
+
});
|
|
426
|
+
cores.set(key, core);
|
|
427
|
+
} else if (opts) core.configure(opts);
|
|
428
|
+
return core;
|
|
429
|
+
};
|
|
430
|
+
class FakeSocket {
|
|
431
|
+
static OPEN = 1;
|
|
432
|
+
static CONNECTING = 0;
|
|
433
|
+
static CLOSING = 2;
|
|
434
|
+
static CLOSED = 3;
|
|
435
|
+
url;
|
|
436
|
+
/** Client→relay frames, for tests that inspect wire traffic. */
|
|
437
|
+
sent = [];
|
|
438
|
+
readyState = FakeSocket.CONNECTING;
|
|
439
|
+
#listeners = /* @__PURE__ */ new Map();
|
|
440
|
+
#session;
|
|
441
|
+
#queuedSends = [];
|
|
442
|
+
#core;
|
|
443
|
+
constructor(url) {
|
|
444
|
+
this.url = url;
|
|
445
|
+
this.#core = coreFor(url);
|
|
446
|
+
queueMicrotask(() => this.#open());
|
|
447
|
+
}
|
|
448
|
+
#open() {
|
|
449
|
+
if (this.readyState !== FakeSocket.CONNECTING) return;
|
|
450
|
+
this.readyState = FakeSocket.OPEN;
|
|
451
|
+
const transport = {
|
|
452
|
+
send: (data) => this.#emit("message", { data }),
|
|
453
|
+
close: () => this.#terminate()
|
|
454
|
+
};
|
|
455
|
+
this.#emit("open", {});
|
|
456
|
+
this.#session = this.#core.connect(transport);
|
|
457
|
+
const queued = this.#queuedSends;
|
|
458
|
+
this.#queuedSends = [];
|
|
459
|
+
for (const data of queued) this.#core.handleMessage(this.#session, data);
|
|
460
|
+
}
|
|
461
|
+
send(data) {
|
|
462
|
+
if (this.readyState !== FakeSocket.OPEN) return;
|
|
463
|
+
this.sent.push(data);
|
|
464
|
+
if (this.#session) this.#core.handleMessage(this.#session, data);
|
|
465
|
+
else this.#queuedSends.push(data);
|
|
466
|
+
}
|
|
467
|
+
close() {
|
|
468
|
+
this.#terminate();
|
|
469
|
+
}
|
|
470
|
+
#terminate() {
|
|
471
|
+
if (this.readyState === FakeSocket.CLOSING || this.readyState === FakeSocket.CLOSED) {
|
|
472
|
+
this.readyState = FakeSocket.CLOSED;
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
this.readyState = FakeSocket.CLOSED;
|
|
476
|
+
const session = this.#session;
|
|
477
|
+
this.#session = void 0;
|
|
478
|
+
if (session) this.#core.detach(session);
|
|
479
|
+
this.#emit("close", {});
|
|
480
|
+
}
|
|
481
|
+
addEventListener(type, listener) {
|
|
482
|
+
let set = this.#listeners.get(type);
|
|
483
|
+
if (!set) {
|
|
484
|
+
set = /* @__PURE__ */ new Set();
|
|
485
|
+
this.#listeners.set(type, set);
|
|
486
|
+
}
|
|
487
|
+
set.add(listener);
|
|
488
|
+
}
|
|
489
|
+
removeEventListener(type, listener) {
|
|
490
|
+
this.#listeners.get(type)?.delete(listener);
|
|
491
|
+
}
|
|
492
|
+
#emit(type, ev) {
|
|
493
|
+
for (const listener of this.#listeners.get(type) ?? []) listener(ev);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
websocketImplementation: FakeSocket,
|
|
498
|
+
relay: coreFor,
|
|
499
|
+
close() {
|
|
500
|
+
for (const core of cores.values()) core.disconnect();
|
|
501
|
+
cores.clear();
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
//#endregion
|
|
506
|
+
//#region src/testing/serve.ts
|
|
507
|
+
/**
|
|
508
|
+
* Serve the fake relay on a real WebSocket port via the optional `ws` peer dependency (dynamic
|
|
509
|
+
* import — `@qntx/nostr/testing` never requires `ws` unless you serve). `port: 0` picks an
|
|
510
|
+
* ephemeral port.
|
|
511
|
+
*/
|
|
512
|
+
async function serveFakeRelay(opts = {}) {
|
|
513
|
+
const { port = 0, host = "127.0.0.1", ...relayOpts } = opts;
|
|
514
|
+
const { WebSocketServer } = await import("ws");
|
|
515
|
+
const wss = new WebSocketServer({
|
|
516
|
+
port,
|
|
517
|
+
host
|
|
518
|
+
});
|
|
519
|
+
await new Promise((resolve, reject) => {
|
|
520
|
+
wss.once("listening", () => resolve());
|
|
521
|
+
wss.once("error", (err) => reject(err));
|
|
522
|
+
});
|
|
523
|
+
const address = wss.address();
|
|
524
|
+
const url = `ws://${host}:${typeof address === "object" && address !== null ? address.port : port}`;
|
|
525
|
+
const core = new FakeRelayCore(url, relayOpts);
|
|
526
|
+
wss.on("connection", (socket) => {
|
|
527
|
+
const session = core.connect({
|
|
528
|
+
send: (data) => socket.send(data),
|
|
529
|
+
close: () => socket.close()
|
|
530
|
+
});
|
|
531
|
+
socket.on("message", (data) => {
|
|
532
|
+
core.handleMessage(session, String(data));
|
|
533
|
+
});
|
|
534
|
+
socket.on("close", () => core.detach(session));
|
|
535
|
+
socket.on("error", () => core.detach(session));
|
|
536
|
+
});
|
|
537
|
+
return {
|
|
538
|
+
url,
|
|
539
|
+
relay: core,
|
|
540
|
+
close: async () => new Promise((resolve) => {
|
|
541
|
+
core.disconnect();
|
|
542
|
+
for (const client of wss.clients) client.terminate();
|
|
543
|
+
wss.close(() => resolve());
|
|
544
|
+
})
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
//#endregion
|
|
548
|
+
//#region src/testing/conformance.ts
|
|
549
|
+
const ALICE_SK = "0000000000000000000000000000000000000000000000000000000000000101";
|
|
550
|
+
const BOB_SK = "0000000000000000000000000000000000000000000000000000000000000102";
|
|
551
|
+
function alice() {
|
|
552
|
+
return Keys.fromSecretKey(ALICE_SK);
|
|
553
|
+
}
|
|
554
|
+
function bob() {
|
|
555
|
+
return Keys.fromSecretKey(BOB_SK);
|
|
556
|
+
}
|
|
557
|
+
function note(keys, content, createdAt, tags = []) {
|
|
558
|
+
return new EventBuilder(1, content).tags(tags).createdAt(createdAt).signWithKeys(keys);
|
|
559
|
+
}
|
|
560
|
+
function replaceable(keys, kind, content, createdAt) {
|
|
561
|
+
return new EventBuilder(kind, content).createdAt(createdAt).signWithKeys(keys);
|
|
562
|
+
}
|
|
563
|
+
function addressable(keys, kind, d, content, createdAt) {
|
|
564
|
+
return new EventBuilder(kind, content).tags([["d", d]]).createdAt(createdAt).signWithKeys(keys);
|
|
565
|
+
}
|
|
566
|
+
function fail(message) {
|
|
567
|
+
throw new Error(`EventStore conformance: ${message}`);
|
|
568
|
+
}
|
|
569
|
+
function eq(got, want, what) {
|
|
570
|
+
if (JSON.stringify(got) !== JSON.stringify(want)) fail(`${what}: expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`);
|
|
571
|
+
}
|
|
572
|
+
function ids(events) {
|
|
573
|
+
return events.map((e) => e.id);
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Framework-agnostic {@link EventStore} conformance cases. Each `run` throws on failure; register
|
|
577
|
+
* them with the test framework of your choice: `for (const c of eventStoreConformanceCases)
|
|
578
|
+
* test(c.name, () => c.run(store()))`.
|
|
579
|
+
*/
|
|
580
|
+
const eventStoreConformanceCases = [
|
|
581
|
+
{
|
|
582
|
+
name: "put accepted then duplicate",
|
|
583
|
+
async run(store) {
|
|
584
|
+
const e = note(alice(), "hello", 1);
|
|
585
|
+
eq(await store.put(e), "accepted", "first put");
|
|
586
|
+
eq(await store.put(e), "duplicate", "second put");
|
|
587
|
+
eq(await store.get(e.id), e, "get after put");
|
|
588
|
+
eq(await store.putMany([note(alice(), "a", 2), note(alice(), "b", 3)]), ["accepted", "accepted"], "putMany results");
|
|
589
|
+
}
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
name: "ephemeral event returns ephemeral and is not stored",
|
|
593
|
+
async run(store) {
|
|
594
|
+
const e = replaceable(alice(), 20001, "ephemeral", 1);
|
|
595
|
+
eq(await store.put(e), "ephemeral", "put ephemeral");
|
|
596
|
+
eq(await store.get(e.id), void 0, "ephemeral not retrievable");
|
|
597
|
+
eq(await store.query([{ kinds: [20001] }]), [], "ephemeral not queryable");
|
|
598
|
+
eq(await store.count([{ kinds: [20001] }]), 0, "ephemeral not counted");
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
name: "replaceable event keeps newest and rejects stale",
|
|
603
|
+
async run(store) {
|
|
604
|
+
const older = replaceable(alice(), 0, "old", 1);
|
|
605
|
+
const newer = replaceable(alice(), 0, "new", 2);
|
|
606
|
+
eq(await store.put(older), "accepted", "first replaceable");
|
|
607
|
+
eq(await store.put(newer), "replaced", "newer replaces");
|
|
608
|
+
eq(await store.put(older), "rejected", "stale replaceable rejected");
|
|
609
|
+
eq(await store.get(older.id), void 0, "replaced event gone");
|
|
610
|
+
eq(ids(await store.query([{
|
|
611
|
+
kinds: [0],
|
|
612
|
+
authors: [alice().publicKey]
|
|
613
|
+
}])), [newer.id], "newest kept");
|
|
614
|
+
}
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
name: "addressable event replaces by address",
|
|
618
|
+
async run(store) {
|
|
619
|
+
const v1 = addressable(alice(), 30001, "x", "v1", 1);
|
|
620
|
+
const v2 = addressable(alice(), 30001, "x", "v2", 2);
|
|
621
|
+
const other = addressable(alice(), 30001, "y", "other", 3);
|
|
622
|
+
eq(await store.put(v1), "accepted", "first addressable");
|
|
623
|
+
eq(await store.put(v2), "replaced", "same address replaced");
|
|
624
|
+
eq(await store.put(v1), "rejected", "stale addressable rejected");
|
|
625
|
+
eq(await store.put(other), "accepted", "different address independent");
|
|
626
|
+
eq(await store.get(v1.id), void 0, "v1 gone");
|
|
627
|
+
eq(ids(await store.query([{
|
|
628
|
+
kinds: [30001],
|
|
629
|
+
authors: [alice().publicKey]
|
|
630
|
+
}])).sort(), [other.id, v2.id].sort(), "independent addresses");
|
|
631
|
+
}
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
name: "deletion removes same-author e-tag targets only",
|
|
635
|
+
async run(store) {
|
|
636
|
+
const mine = note(alice(), "mine", 1);
|
|
637
|
+
const theirs = note(bob(), "theirs", 1);
|
|
638
|
+
await store.put(mine);
|
|
639
|
+
await store.put(theirs);
|
|
640
|
+
const delForeign = EventBuilder.deletion([theirs.id]).createdAt(2).signWithKeys(alice());
|
|
641
|
+
eq(await store.put(delForeign), "deleted", "kind 5 accepted");
|
|
642
|
+
eq(await store.get(theirs.id), theirs, "other author's event survives");
|
|
643
|
+
const delMine = EventBuilder.deletion([mine.id]).createdAt(3).signWithKeys(alice());
|
|
644
|
+
eq(await store.put(delMine), "deleted", "kind 5 accepted");
|
|
645
|
+
eq(await store.get(mine.id), void 0, "own event deleted");
|
|
646
|
+
eq(await store.query([{ ids: [mine.id] }]), [], "deleted event not queryable");
|
|
647
|
+
eq(await store.put(mine), "duplicate", "deleted event cannot be re-put");
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
name: "deletion removes addressable via a tag and tombstones re-put",
|
|
652
|
+
async run(store) {
|
|
653
|
+
const target = addressable(alice(), 30001, "z", "target", 1);
|
|
654
|
+
await store.put(target);
|
|
655
|
+
const coord = `30001:${alice().publicKey}:z`;
|
|
656
|
+
const del = EventBuilder.deletion([{ address: coord }], "").createdAt(2).signWithKeys(alice());
|
|
657
|
+
eq(await store.put(del), "deleted", "a-tag deletion");
|
|
658
|
+
eq(await store.get(target.id), void 0, "addressable deleted");
|
|
659
|
+
const stillCovered = addressable(alice(), 30001, "z", "covered", 1);
|
|
660
|
+
eq(await store.put(stillCovered), "duplicate", "tombstoned address rejects re-put");
|
|
661
|
+
eq(await store.query([{
|
|
662
|
+
kinds: [30001],
|
|
663
|
+
authors: [alice().publicKey]
|
|
664
|
+
}]), [], "none left");
|
|
665
|
+
const after = addressable(alice(), 30001, "z", "after", 3);
|
|
666
|
+
eq(await store.put(after), "accepted", "events newer than the deletion are kept");
|
|
667
|
+
}
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
name: "query orders newest first and applies limit",
|
|
671
|
+
async run(store) {
|
|
672
|
+
const a = note(alice(), "a", 1);
|
|
673
|
+
const b = note(alice(), "b", 3);
|
|
674
|
+
const c = note(alice(), "c", 2);
|
|
675
|
+
await store.putMany([
|
|
676
|
+
a,
|
|
677
|
+
b,
|
|
678
|
+
c
|
|
679
|
+
]);
|
|
680
|
+
eq(ids(await store.query([{
|
|
681
|
+
kinds: [1],
|
|
682
|
+
authors: [alice().publicKey]
|
|
683
|
+
}])), [
|
|
684
|
+
b.id,
|
|
685
|
+
c.id,
|
|
686
|
+
a.id
|
|
687
|
+
], "descending created_at");
|
|
688
|
+
eq(ids(await store.query([{
|
|
689
|
+
kinds: [1],
|
|
690
|
+
authors: [alice().publicKey],
|
|
691
|
+
limit: 2
|
|
692
|
+
}])), [b.id, c.id], "limit applies");
|
|
693
|
+
}
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
name: "query filters by kinds, authors, #e, #p, since, until",
|
|
697
|
+
async run(store) {
|
|
698
|
+
const target = note(bob(), "target", 1);
|
|
699
|
+
const tagged = note(alice(), "tagged", 2, [["e", target.id], ["p", bob().publicKey]]);
|
|
700
|
+
const plain = note(alice(), "plain", 3);
|
|
701
|
+
await store.putMany([
|
|
702
|
+
target,
|
|
703
|
+
tagged,
|
|
704
|
+
plain
|
|
705
|
+
]);
|
|
706
|
+
eq((await store.query([{ kinds: [1] }])).length, 3, "kind filter");
|
|
707
|
+
eq(ids(await store.query([{ authors: [bob().publicKey] }])), [target.id], "authors");
|
|
708
|
+
eq(ids(await store.query([{ "#e": [target.id] }])), [tagged.id], "#e");
|
|
709
|
+
eq(ids(await store.query([{ "#p": [bob().publicKey] }])), [tagged.id], "#p");
|
|
710
|
+
eq(ids(await store.query([{
|
|
711
|
+
kinds: [1],
|
|
712
|
+
since: 2
|
|
713
|
+
}])), [plain.id, tagged.id], "since");
|
|
714
|
+
eq(ids(await store.query([{
|
|
715
|
+
kinds: [1],
|
|
716
|
+
until: 2
|
|
717
|
+
}])), [tagged.id, target.id], "until");
|
|
718
|
+
eq(ids(await store.query([{ ids: [target.id, tagged.id] }])).sort(), [tagged.id, target.id].sort(), "ids filter");
|
|
719
|
+
}
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
name: "count matches query cardinality across overlapping filters",
|
|
723
|
+
async run(store) {
|
|
724
|
+
const a = note(alice(), "a", 1);
|
|
725
|
+
const b = note(bob(), "b", 2);
|
|
726
|
+
await store.putMany([a, b]);
|
|
727
|
+
eq(await store.count([{ kinds: [1] }]), 2, "count all");
|
|
728
|
+
eq(await store.count([
|
|
729
|
+
{ authors: [alice().publicKey] },
|
|
730
|
+
{ authors: [bob().publicKey] },
|
|
731
|
+
{ ids: [a.id] }
|
|
732
|
+
]), 2, "count dedupes union");
|
|
733
|
+
eq(await store.count([{ kinds: [2] }]), 0, "count none");
|
|
734
|
+
}
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
name: "negentropyItems sorted by created_at then id and respects limit",
|
|
738
|
+
async run(store) {
|
|
739
|
+
const a = note(alice(), "a", 2);
|
|
740
|
+
const b = note(alice(), "b", 1);
|
|
741
|
+
const c = note(bob(), "c", 3);
|
|
742
|
+
await store.putMany([
|
|
743
|
+
a,
|
|
744
|
+
b,
|
|
745
|
+
c
|
|
746
|
+
]);
|
|
747
|
+
eq((await store.negentropyItems({ kinds: [1] })).map((i) => i.id), [
|
|
748
|
+
b.id,
|
|
749
|
+
a.id,
|
|
750
|
+
c.id
|
|
751
|
+
], "created_at asc ordering");
|
|
752
|
+
eq((await store.negentropyItems({
|
|
753
|
+
kinds: [1],
|
|
754
|
+
limit: 2
|
|
755
|
+
})).map((i) => i.id), [a.id, c.id], "limit keeps newest two, presented ascending");
|
|
756
|
+
eq(await store.negentropyItems({ kinds: [2] }), [], "no match");
|
|
757
|
+
eq(await store.negentropyItems({ authors: [bob().publicKey] }), [{
|
|
758
|
+
id: c.id,
|
|
759
|
+
created_at: 3
|
|
760
|
+
}], "filter");
|
|
761
|
+
}
|
|
762
|
+
},
|
|
763
|
+
{
|
|
764
|
+
name: "outbox bounds roundtrip",
|
|
765
|
+
async run(store) {
|
|
766
|
+
eq(await store.getOutboxBound(alice().publicKey, 1), void 0, "unset bound");
|
|
767
|
+
await store.setOutboxBound(alice().publicKey, 1, {
|
|
768
|
+
oldest: 10,
|
|
769
|
+
newest: 20
|
|
770
|
+
});
|
|
771
|
+
eq(await store.getOutboxBound(alice().publicKey, 1), {
|
|
772
|
+
oldest: 10,
|
|
773
|
+
newest: 20
|
|
774
|
+
}, "bound read");
|
|
775
|
+
await store.setOutboxBound(alice().publicKey, 1, {
|
|
776
|
+
oldest: 5,
|
|
777
|
+
newest: 30
|
|
778
|
+
});
|
|
779
|
+
eq(await store.getOutboxBound(alice().publicKey, 1), {
|
|
780
|
+
oldest: 5,
|
|
781
|
+
newest: 30
|
|
782
|
+
}, "bound overwrite");
|
|
783
|
+
}
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
name: "remove deletes by id and tombstones",
|
|
787
|
+
async run(store) {
|
|
788
|
+
const a = note(alice(), "a", 1);
|
|
789
|
+
await store.put(a);
|
|
790
|
+
eq(await store.remove([a.id]), 1, "remove count");
|
|
791
|
+
eq(await store.get(a.id), void 0, "removed");
|
|
792
|
+
eq(await store.remove([a.id]), 0, "second remove is noop");
|
|
793
|
+
eq(await store.put(a), "duplicate", "removed id tombstoned");
|
|
794
|
+
}
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
name: "clear wipes events and bounds",
|
|
798
|
+
async run(store) {
|
|
799
|
+
const a = note(alice(), "a", 1);
|
|
800
|
+
await store.put(a);
|
|
801
|
+
await store.setOutboxBound(alice().publicKey, 1, {
|
|
802
|
+
oldest: 1,
|
|
803
|
+
newest: 1
|
|
804
|
+
});
|
|
805
|
+
await store.clear();
|
|
806
|
+
eq(await store.get(a.id), void 0, "event gone");
|
|
807
|
+
eq(await store.query([{ kinds: [1] }]), [], "query empty");
|
|
808
|
+
eq(await store.getOutboxBound(alice().publicKey, 1), void 0, "bound gone");
|
|
809
|
+
eq(await store.put(a), "accepted", "store usable after clear");
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
];
|
|
813
|
+
//#endregion
|
|
814
|
+
//#region src/testing/nip46.ts
|
|
815
|
+
/**
|
|
816
|
+
* In-process NIP-46 remote signer: subscribes to kind:24133 requests on `relayUrl` and answers as a
|
|
817
|
+
* bunker, encrypted to `clientPubkey` with NIP-44.
|
|
818
|
+
*/
|
|
819
|
+
function createFakeNip46Signer(opts) {
|
|
820
|
+
const bunkerKeys = opts.bunkerSk !== void 0 && opts.bunkerSk !== "" ? Keys.fromSecretKey(opts.bunkerSk) : Keys.generate();
|
|
821
|
+
const userKeys = opts.userSk !== void 0 && opts.userSk !== "" ? Keys.fromSecretKey(opts.userSk) : Keys.generate();
|
|
822
|
+
const convKey = getConversationKey(bunkerKeys.secretKey.bytes, opts.clientPubkey);
|
|
823
|
+
const handled = /* @__PURE__ */ new Set();
|
|
824
|
+
const authPending = /* @__PURE__ */ new Set();
|
|
825
|
+
const authMethods = new Set(opts.authUrlMethods ?? ["connect"]);
|
|
826
|
+
let closed = false;
|
|
827
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
828
|
+
const listen = (relayUrl) => {
|
|
829
|
+
const ws = new opts.network.websocketImplementation(relayUrl);
|
|
830
|
+
ws.addEventListener("open", () => {
|
|
831
|
+
ws.send(JSON.stringify([
|
|
832
|
+
"REQ",
|
|
833
|
+
"fake-nip46-signer",
|
|
834
|
+
{
|
|
835
|
+
kinds: [Kind.NostrConnect],
|
|
836
|
+
"#p": [bunkerKeys.publicKey]
|
|
837
|
+
}
|
|
838
|
+
]));
|
|
839
|
+
});
|
|
840
|
+
ws.addEventListener("message", (ev) => onMessage(ev, ws));
|
|
841
|
+
sockets.add(ws);
|
|
842
|
+
};
|
|
843
|
+
const onMessage = (ev, ws) => {
|
|
844
|
+
if (typeof ev !== "object" || ev === null || !("data" in ev)) return;
|
|
845
|
+
const { data } = ev;
|
|
846
|
+
let msg;
|
|
847
|
+
try {
|
|
848
|
+
msg = JSON.parse(String(data));
|
|
849
|
+
} catch {
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
if (!Array.isArray(msg) || msg[0] !== "EVENT") return;
|
|
853
|
+
const rawEvent = msg.at(2);
|
|
854
|
+
if (!isRecord(rawEvent)) return;
|
|
855
|
+
const { kind, pubkey, content, id } = rawEvent;
|
|
856
|
+
if (kind !== Kind.NostrConnect) return;
|
|
857
|
+
if (typeof pubkey !== "string" || typeof content !== "string" || typeof id !== "string") return;
|
|
858
|
+
if (pubkey !== opts.clientPubkey || handled.has(id)) return;
|
|
859
|
+
try {
|
|
860
|
+
const req = decodeNip46Request(decrypt(content, convKey));
|
|
861
|
+
if (opts.authUrl !== void 0 && opts.authUrl !== "" && authMethods.has(req.method) && !authPending.has(req.id)) {
|
|
862
|
+
authPending.add(req.id);
|
|
863
|
+
reply(ws, opts.clientPubkey, req.id, "auth_url", opts.authUrl);
|
|
864
|
+
}
|
|
865
|
+
handled.add(id);
|
|
866
|
+
opts.requests?.push({
|
|
867
|
+
method: req.method,
|
|
868
|
+
params: req.params
|
|
869
|
+
});
|
|
870
|
+
let result;
|
|
871
|
+
let error;
|
|
872
|
+
switch (opts.emptyMethods?.includes(req.method) === true ? "#empty" : req.method) {
|
|
873
|
+
case "connect":
|
|
874
|
+
result = opts.connectResult ?? "ack";
|
|
875
|
+
break;
|
|
876
|
+
case "get_public_key":
|
|
877
|
+
result = userKeys.publicKey;
|
|
878
|
+
break;
|
|
879
|
+
case "ping":
|
|
880
|
+
result = "pong";
|
|
881
|
+
break;
|
|
882
|
+
case "switch_relays":
|
|
883
|
+
result = opts.switchRelays === void 0 ? "null" : JSON.stringify(opts.switchRelays);
|
|
884
|
+
break;
|
|
885
|
+
case "logout":
|
|
886
|
+
result = "ack";
|
|
887
|
+
break;
|
|
888
|
+
case "sign_event": {
|
|
889
|
+
const rawTemplate = JSON.parse(req.params.at(0) ?? "null");
|
|
890
|
+
if (!isRecord(rawTemplate) || typeof rawTemplate["kind"] !== "number" || !Array.isArray(rawTemplate["tags"]) || !rawTemplate["tags"].every(isTag) || typeof rawTemplate["content"] !== "string" || typeof rawTemplate["created_at"] !== "number") throw new Error("invalid sign_event template");
|
|
891
|
+
result = JSON.stringify(finalizeEvent({
|
|
892
|
+
kind: rawTemplate["kind"],
|
|
893
|
+
tags: rawTemplate["tags"],
|
|
894
|
+
content: rawTemplate["content"],
|
|
895
|
+
created_at: rawTemplate["created_at"]
|
|
896
|
+
}, userKeys.secretKey));
|
|
897
|
+
break;
|
|
898
|
+
}
|
|
899
|
+
case "#empty": break;
|
|
900
|
+
default: error = `unsupported method ${req.method}`;
|
|
901
|
+
}
|
|
902
|
+
if (authPending.has(req.id) && opts.authReplyDelayMs !== void 0) {
|
|
903
|
+
const pendingId = req.id;
|
|
904
|
+
const pendingResult = result;
|
|
905
|
+
const pendingError = error;
|
|
906
|
+
setTimeout(() => reply(ws, opts.clientPubkey, pendingId, pendingResult, pendingError), opts.authReplyDelayMs);
|
|
907
|
+
} else reply(ws, opts.clientPubkey, req.id, result, error);
|
|
908
|
+
} catch {
|
|
909
|
+
handled.add(id);
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
const reply = (ws, clientPubkey, id, result, error) => {
|
|
913
|
+
if (closed) return;
|
|
914
|
+
const payload = (opts.encodeResponse ?? encodeNip46Response)({
|
|
915
|
+
id,
|
|
916
|
+
result,
|
|
917
|
+
error
|
|
918
|
+
});
|
|
919
|
+
const event = finalizeEvent({
|
|
920
|
+
kind: Kind.NostrConnect,
|
|
921
|
+
tags: [["p", clientPubkey]],
|
|
922
|
+
content: encrypt(payload, convKey),
|
|
923
|
+
created_at: Math.floor(Date.now() / 1e3)
|
|
924
|
+
}, bunkerKeys.secretKey);
|
|
925
|
+
ws.send(JSON.stringify(["EVENT", event]));
|
|
926
|
+
};
|
|
927
|
+
listen(opts.relayUrl);
|
|
928
|
+
return {
|
|
929
|
+
bunkerPubkey: bunkerKeys.publicKey,
|
|
930
|
+
userPublicKey: userKeys.publicKey,
|
|
931
|
+
confirmHandshake(secret) {
|
|
932
|
+
const payload = encodeNip46Response({
|
|
933
|
+
id: "handshake",
|
|
934
|
+
result: secret
|
|
935
|
+
});
|
|
936
|
+
const event = finalizeEvent({
|
|
937
|
+
kind: Kind.NostrConnect,
|
|
938
|
+
tags: [["p", opts.clientPubkey]],
|
|
939
|
+
content: encrypt(payload, convKey),
|
|
940
|
+
created_at: Math.floor(Date.now() / 1e3)
|
|
941
|
+
}, bunkerKeys.secretKey);
|
|
942
|
+
opts.network.relay(opts.relayUrl).inject(event);
|
|
943
|
+
},
|
|
944
|
+
attach(relayUrl) {
|
|
945
|
+
listen(relayUrl);
|
|
946
|
+
},
|
|
947
|
+
close() {
|
|
948
|
+
closed = true;
|
|
949
|
+
for (const ws of sockets) ws.close();
|
|
950
|
+
sockets.clear();
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
//#endregion
|
|
955
|
+
export { createFakeNip46Signer, createFakeRelayNetwork, eventStoreConformanceCases, serveFakeRelay };
|
|
956
|
+
|
|
957
|
+
//# sourceMappingURL=testing.mjs.map
|