@orangecheck/nostr-core 0.1.1 → 0.2.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/index.d.mts CHANGED
@@ -14,19 +14,15 @@ interface PublishResult {
14
14
  reason?: string;
15
15
  attempts: number;
16
16
  }
17
- interface Filter {
17
+ type IndexableTagName = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z' | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z';
18
+ type IndexableTagFilter = `#${IndexableTagName}`;
19
+ interface Filter extends Partial<Record<IndexableTagFilter, string[]>> {
18
20
  kinds?: number[];
19
21
  authors?: string[];
20
22
  ids?: string[];
21
23
  limit?: number;
22
24
  since?: number;
23
25
  until?: number;
24
- '#d'?: string[];
25
- '#t'?: string[];
26
- '#poll_id'?: string[];
27
- '#voter'?: string[];
28
- '#creator'?: string[];
29
- [key: `#${string}`]: string[] | undefined;
30
26
  }
31
27
  interface QueryResult {
32
28
  events: NostrEvent[];
@@ -37,7 +33,8 @@ interface QueryResult {
37
33
  events: number;
38
34
  }[];
39
35
  }
40
- declare function publishEvent(event: NostrEvent, relays?: readonly string[], timeoutMs?: number): Promise<PublishResult[]>;
41
- declare function queryEvents(filter: Filter, relays?: readonly string[], timeoutMs?: number): Promise<QueryResult>;
36
+ type AuthSigner = (challenge: string, relayUrl: string) => NostrEvent | Promise<NostrEvent>;
37
+ declare function publishEvent(event: NostrEvent, relays?: readonly string[], timeoutMs?: number, auth?: AuthSigner): Promise<PublishResult[]>;
38
+ declare function queryEvents(filter: Filter, relays?: readonly string[], timeoutMs?: number, auth?: AuthSigner): Promise<QueryResult>;
42
39
 
43
- export { DEFAULT_RELAYS, type Filter, type NostrEvent, type PublishResult, type QueryResult, publishEvent, queryEvents };
40
+ export { type AuthSigner, DEFAULT_RELAYS, type Filter, type IndexableTagFilter, type NostrEvent, type PublishResult, type QueryResult, publishEvent, queryEvents };
package/dist/index.d.ts CHANGED
@@ -14,19 +14,15 @@ interface PublishResult {
14
14
  reason?: string;
15
15
  attempts: number;
16
16
  }
17
- interface Filter {
17
+ type IndexableTagName = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z' | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z';
18
+ type IndexableTagFilter = `#${IndexableTagName}`;
19
+ interface Filter extends Partial<Record<IndexableTagFilter, string[]>> {
18
20
  kinds?: number[];
19
21
  authors?: string[];
20
22
  ids?: string[];
21
23
  limit?: number;
22
24
  since?: number;
23
25
  until?: number;
24
- '#d'?: string[];
25
- '#t'?: string[];
26
- '#poll_id'?: string[];
27
- '#voter'?: string[];
28
- '#creator'?: string[];
29
- [key: `#${string}`]: string[] | undefined;
30
26
  }
31
27
  interface QueryResult {
32
28
  events: NostrEvent[];
@@ -37,7 +33,8 @@ interface QueryResult {
37
33
  events: number;
38
34
  }[];
39
35
  }
40
- declare function publishEvent(event: NostrEvent, relays?: readonly string[], timeoutMs?: number): Promise<PublishResult[]>;
41
- declare function queryEvents(filter: Filter, relays?: readonly string[], timeoutMs?: number): Promise<QueryResult>;
36
+ type AuthSigner = (challenge: string, relayUrl: string) => NostrEvent | Promise<NostrEvent>;
37
+ declare function publishEvent(event: NostrEvent, relays?: readonly string[], timeoutMs?: number, auth?: AuthSigner): Promise<PublishResult[]>;
38
+ declare function queryEvents(filter: Filter, relays?: readonly string[], timeoutMs?: number, auth?: AuthSigner): Promise<QueryResult>;
42
39
 
43
- export { DEFAULT_RELAYS, type Filter, type NostrEvent, type PublishResult, type QueryResult, publishEvent, queryEvents };
40
+ export { type AuthSigner, DEFAULT_RELAYS, type Filter, type IndexableTagFilter, type NostrEvent, type PublishResult, type QueryResult, publishEvent, queryEvents };
package/dist/index.js CHANGED
@@ -2,11 +2,19 @@
2
2
 
3
3
  // src/index.ts
4
4
  var _RELAYS = [
5
- "wss://relay.nostr.band",
6
5
  "wss://nos.lol",
7
6
  "wss://relay.primal.net",
8
7
  "wss://offchain.pub",
9
- // First-party family relay kind allowlist 30078–30086 + canonical
8
+ // relay.nostr.band sat here until 2026-09-03, when it stopped accepting
9
+ // TCP connections altogether (DNS resolves, port 443 never handshakes, and
10
+ // the apex nostr.band is down too — from two independent networks).
11
+ // relay.snort.social replaces it to keep the breadth this set exists for;
12
+ // it answered every probe. Note it ACCEPTS unindexed tag filters by
13
+ // ignoring them rather than rejecting, so it will happily return a flood of
14
+ // unrelated events for a multi-letter filter — which the Filter type in
15
+ // this file now makes impossible to express.
16
+ "wss://relay.snort.social",
17
+ // First-party family relay — kind allowlist 30078-30087 + 30110-30114 + canonical
10
18
  // OC d-tag prefixes. Always co-published with public relays; never
11
19
  // the only copy. See https://github.com/orangecheck/oc-relay-infra.
12
20
  "wss://relay.ochk.io"
@@ -17,7 +25,7 @@ function parseFrame(raw) {
17
25
  const arr = JSON.parse(raw);
18
26
  if (!Array.isArray(arr) || arr.length === 0) return null;
19
27
  const type = arr[0];
20
- if (type === "OK" || type === "EVENT" || type === "EOSE" || type === "NOTICE" || type === "CLOSED") {
28
+ if (type === "OK" || type === "EVENT" || type === "EOSE" || type === "NOTICE" || type === "CLOSED" || type === "AUTH") {
21
29
  return { type, payload: arr.slice(1) };
22
30
  }
23
31
  return null;
@@ -34,13 +42,13 @@ var DEFAULT_RETRY = {
34
42
  function delay(ms) {
35
43
  return new Promise((resolve) => setTimeout(resolve, ms));
36
44
  }
37
- async function publishOne(url, event, retry) {
45
+ async function publishOne(url, event, retry, auth) {
38
46
  let attempts = 0;
39
47
  let backoff = retry.initialBackoffMs;
40
48
  let lastReason;
41
49
  while (attempts < retry.attempts) {
42
50
  attempts++;
43
- const attempt = await attemptPublish(url, event, retry.timeoutMs);
51
+ const attempt = await attemptPublish(url, event, retry.timeoutMs, auth);
44
52
  if (attempt.ok) {
45
53
  return {
46
54
  relay: url,
@@ -64,9 +72,10 @@ async function publishOne(url, event, retry) {
64
72
  ...lastReason ? { reason: lastReason } : {}
65
73
  };
66
74
  }
67
- function attemptPublish(url, event, timeoutMs) {
75
+ function attemptPublish(url, event, timeoutMs, auth) {
68
76
  return new Promise((resolve) => {
69
77
  let settled = false;
78
+ let authed = false;
70
79
  let ws = null;
71
80
  const timer = setTimeout(() => {
72
81
  if (settled) return;
@@ -83,6 +92,16 @@ function attemptPublish(url, event, timeoutMs) {
83
92
  ws.onmessage = (msg) => {
84
93
  const frame = parseFrame(msg.data);
85
94
  if (!frame) return;
95
+ if (frame.type === "AUTH" && auth && !authed) {
96
+ authed = true;
97
+ const challenge = String(frame.payload[0] ?? "");
98
+ void Promise.resolve(auth(challenge, url)).then((authEvent) => {
99
+ ws?.send(JSON.stringify(["AUTH", authEvent]));
100
+ ws?.send(JSON.stringify(["EVENT", event]));
101
+ }).catch(() => {
102
+ });
103
+ return;
104
+ }
86
105
  if (frame.type === "OK" && frame.payload[0] === event.id) {
87
106
  if (settled) return;
88
107
  settled = true;
@@ -124,11 +143,11 @@ function attemptPublish(url, event, timeoutMs) {
124
143
  }
125
144
  });
126
145
  }
127
- async function publishEvent(event, relays = DEFAULT_RELAYS, timeoutMs = 5e3) {
146
+ async function publishEvent(event, relays = DEFAULT_RELAYS, timeoutMs = 5e3, auth) {
128
147
  const retry = { ...DEFAULT_RETRY, timeoutMs };
129
- return Promise.all(relays.map((r) => publishOne(r, event, retry)));
148
+ return Promise.all(relays.map((r) => publishOne(r, event, retry, auth)));
130
149
  }
131
- async function queryEvents(filter, relays = DEFAULT_RELAYS, timeoutMs = 1500) {
150
+ async function queryEvents(filter, relays = DEFAULT_RELAYS, timeoutMs = 1500, auth) {
132
151
  const subId = "ocnc-" + Math.random().toString(36).slice(2, 10);
133
152
  const byId = /* @__PURE__ */ new Map();
134
153
  const status = [];
@@ -136,6 +155,7 @@ async function queryEvents(filter, relays = DEFAULT_RELAYS, timeoutMs = 1500) {
136
155
  relays.map(
137
156
  (url) => new Promise((resolve) => {
138
157
  let settled = false;
158
+ let authed = false;
139
159
  let count = 0;
140
160
  let reason;
141
161
  let ws = null;
@@ -160,6 +180,16 @@ async function queryEvents(filter, relays = DEFAULT_RELAYS, timeoutMs = 1500) {
160
180
  ws.onmessage = (msg) => {
161
181
  const frame = parseFrame(msg.data);
162
182
  if (!frame) return;
183
+ if (frame.type === "AUTH" && auth && !authed) {
184
+ authed = true;
185
+ const challenge = String(frame.payload[0] ?? "");
186
+ void Promise.resolve(auth(challenge, url)).then((authEvent) => {
187
+ ws?.send(JSON.stringify(["AUTH", authEvent]));
188
+ ws?.send(JSON.stringify(["REQ", subId, filter]));
189
+ }).catch(() => {
190
+ });
191
+ return;
192
+ }
163
193
  if (frame.type === "EVENT" && frame.payload[0] === subId) {
164
194
  const event = frame.payload[1];
165
195
  if (event && event.id) {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @orangecheck/nostr-core\n *\n * Browser-compatible Nostr client used by every OrangeCheck family web\n * app. Raw NIP-01 over WebSocket against a list of relays. Every operation\n * races all relays in parallel and reports per-relay status so the caller\n * can distinguish \"nobody replied\" from \"one relay rejected.\" Retries with\n * exponential backoff on transport errors only.\n *\n * No dependencies — uses the platform `WebSocket` global. Works in any\n * runtime that ships a WHATWG WebSocket (browser, Node 22+, Deno, Bun,\n * Cloudflare Workers).\n *\n * Source-of-truth `DEFAULT_RELAYS` for the OC family. Co-publishes to four\n * public relays plus `wss://relay.ochk.io` (the family's first-party\n * kind-allowlisted relay — see https://github.com/orangecheck/oc-relay-infra).\n *\n * **Hard invariant:** `DEFAULT_RELAYS` MUST contain at least two entries,\n * and MUST NOT be `relay.ochk.io` alone. Enforced at the type level — a\n * future engineer simplifying to ours-only fails `tsc`. See `_validate`\n * below.\n */\n\n// ─────────────────────────────────────────────────────────────────────────\n// Build-time invariants + default relay set.\n// ─────────────────────────────────────────────────────────────────────────\n\n/**\n * Family-relay invariants applied to `DEFAULT_RELAYS`. The relay set must:\n * 1. Contain at least two relays — single-relay defaults are always wrong\n * because the family's BYPASS principle requires public-relay co-publish.\n * 2. Not be `wss://relay.ochk.io` alone — relay.ochk.io is additive, never\n * a single point of failure. See oc-relay-infra/BYPASS.md.\n *\n * If `T` violates either rule, this resolves to `never` and the assignment\n * below fails at `tsc` time.\n */\ntype ValidRelaySet<T extends readonly string[]> =\n T['length'] extends 0 | 1 ? never :\n T extends readonly ['wss://relay.ochk.io'] ? never :\n T;\n\nconst _RELAYS: ValidRelaySet<readonly [\n 'wss://relay.nostr.band',\n 'wss://nos.lol',\n 'wss://relay.primal.net',\n 'wss://offchain.pub',\n 'wss://relay.ochk.io',\n]> = [\n 'wss://relay.nostr.band',\n 'wss://nos.lol',\n 'wss://relay.primal.net',\n 'wss://offchain.pub',\n // First-party family relay — kind allowlist 30078–30086 + canonical\n // OC d-tag prefixes. Always co-published with public relays; never\n // the only copy. See https://github.com/orangecheck/oc-relay-infra.\n 'wss://relay.ochk.io',\n] as const;\n\n/**\n * Default relay set for OrangeCheck family Nostr publishes + queries.\n *\n * Frozen at runtime; consumers MAY pass an explicit `relays` arg to any\n * function in this package to override.\n *\n * @example\n * ```ts\n * import { DEFAULT_RELAYS, publishEvent } from '@orangecheck/nostr-core';\n *\n * const results = await publishEvent(myEvent);\n * // → publishes to all 5 relays in DEFAULT_RELAYS in parallel\n * console.log(`accepted on ${results.filter(r => r.ok).length}/${results.length}`);\n * ```\n */\nexport const DEFAULT_RELAYS: readonly string[] = Object.freeze([..._RELAYS]);\n\n// ─────────────────────────────────────────────────────────────────────────\n// Wire types — NIP-01 event + filter + result shapes.\n// ─────────────────────────────────────────────────────────────────────────\n\nexport interface NostrEvent {\n id: string;\n kind: number;\n pubkey: string;\n created_at: number;\n content: string;\n tags: string[][];\n sig: string;\n}\n\nexport interface PublishResult {\n relay: string;\n ok: boolean;\n reason?: string;\n attempts: number;\n}\n\nexport interface Filter {\n kinds?: number[];\n authors?: string[];\n ids?: string[];\n limit?: number;\n since?: number;\n until?: number;\n /** NIP-12 indexable `d`-tag filter. */\n '#d'?: string[];\n /** NIP-12 indexable single-letter tag filter. */\n '#t'?: string[];\n /** Used by OC Vote (kind 30081 ballots). */\n '#poll_id'?: string[];\n /** Used by OC Vote (kind 30081 ballots). */\n '#voter'?: string[];\n /** Used by OC Vote (kind 30080 polls). */\n '#creator'?: string[];\n /** Other indexable single-letter tags clients may filter on. */\n [key: `#${string}`]: string[] | undefined;\n}\n\nexport interface QueryResult {\n events: NostrEvent[];\n relayStatus: { relay: string; ok: boolean; reason?: string; events: number }[];\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Internal — frame parsing + retry config.\n// ─────────────────────────────────────────────────────────────────────────\n\ntype FrameType = 'OK' | 'EVENT' | 'EOSE' | 'NOTICE' | 'CLOSED';\ninterface RelayFrame {\n type: FrameType;\n payload: unknown[];\n}\n\nfunction parseFrame(raw: string): RelayFrame | null {\n try {\n const arr = JSON.parse(raw) as unknown[];\n if (!Array.isArray(arr) || arr.length === 0) return null;\n const type = arr[0];\n if (\n type === 'OK' ||\n type === 'EVENT' ||\n type === 'EOSE' ||\n type === 'NOTICE' ||\n type === 'CLOSED'\n ) {\n return { type: type as FrameType, payload: arr.slice(1) };\n }\n return null;\n } catch {\n return null;\n }\n}\n\ninterface RetryOptions {\n attempts: number;\n timeoutMs: number;\n initialBackoffMs: number;\n maxBackoffMs: number;\n}\n\nconst DEFAULT_RETRY: RetryOptions = {\n attempts: 3,\n timeoutMs: 5000,\n initialBackoffMs: 500,\n maxBackoffMs: 4000,\n};\n\nfunction delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Publish — write an event to one or more relays in parallel, with retry.\n// ─────────────────────────────────────────────────────────────────────────\n\nasync function publishOne(\n url: string,\n event: NostrEvent,\n retry: RetryOptions\n): Promise<PublishResult> {\n let attempts = 0;\n let backoff = retry.initialBackoffMs;\n let lastReason: string | undefined;\n\n while (attempts < retry.attempts) {\n attempts++;\n const attempt = await attemptPublish(url, event, retry.timeoutMs);\n if (attempt.ok) {\n return {\n relay: url,\n ok: true,\n attempts,\n ...(attempt.reason ? { reason: attempt.reason } : {}),\n };\n }\n lastReason = attempt.reason;\n if (attempt.retryable && attempts < retry.attempts) {\n await delay(backoff);\n backoff = Math.min(backoff * 2, retry.maxBackoffMs);\n continue;\n }\n break;\n }\n return {\n relay: url,\n ok: false,\n attempts,\n ...(lastReason ? { reason: lastReason } : {}),\n };\n}\n\nfunction attemptPublish(\n url: string,\n event: NostrEvent,\n timeoutMs: number\n): Promise<{ ok: boolean; reason?: string; retryable: boolean }> {\n return new Promise((resolve) => {\n let settled = false;\n let ws: WebSocket | null = null;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try {\n ws?.close();\n } catch {}\n resolve({ ok: false, reason: 'timeout', retryable: true });\n }, timeoutMs);\n try {\n ws = new WebSocket(url);\n ws.onopen = () => ws?.send(JSON.stringify(['EVENT', event]));\n ws.onmessage = (msg) => {\n const frame = parseFrame(msg.data as string);\n if (!frame) return;\n if (frame.type === 'OK' && frame.payload[0] === event.id) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n try {\n ws?.close();\n } catch {}\n const ok = frame.payload[1] === true;\n const reason = frame.payload[2] as string | undefined;\n resolve({\n ok,\n ...(reason ? { reason } : {}),\n retryable: false,\n });\n }\n };\n ws.onerror = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n resolve({ ok: false, reason: 'websocket_error', retryable: true });\n };\n ws.onclose = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n resolve({ ok: false, reason: 'closed_early', retryable: true });\n };\n } catch (err) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n resolve({\n ok: false,\n reason: err instanceof Error ? err.message : 'unknown',\n retryable: true,\n });\n }\n });\n}\n\n/**\n * Publish a NIP-01 event to all `relays` in parallel. Returns one\n * `PublishResult` per relay. Default timeout 5000ms.\n */\nexport async function publishEvent(\n event: NostrEvent,\n relays: readonly string[] = DEFAULT_RELAYS,\n timeoutMs = 5000\n): Promise<PublishResult[]> {\n const retry: RetryOptions = { ...DEFAULT_RETRY, timeoutMs };\n return Promise.all(relays.map((r) => publishOne(r, event, retry)));\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Query — REQ → EOSE → close. Races all relays; first to EOSE wins.\n// ─────────────────────────────────────────────────────────────────────────\n\n/**\n * Issue a NIP-01 REQ across all `relays` in parallel. Returns deduplicated\n * events sorted newest-first plus per-relay status.\n *\n * Default timeout 1500ms — short enough that a momentary blip on any one\n * relay (including relay.ochk.io) never holds up the racing reads. Pass an\n * explicit `timeoutMs` for slow filters or for use cases where waiting on\n * the slowest relay matters.\n */\nexport async function queryEvents(\n filter: Filter,\n relays: readonly string[] = DEFAULT_RELAYS,\n timeoutMs = 1500\n): Promise<QueryResult> {\n const subId = 'ocnc-' + Math.random().toString(36).slice(2, 10);\n const byId = new Map<string, NostrEvent>();\n const status: QueryResult['relayStatus'] = [];\n\n await Promise.all(\n relays.map(\n (url) =>\n new Promise<void>((resolve) => {\n let settled = false;\n let count = 0;\n let reason: string | undefined;\n let ws: WebSocket | null = null;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try {\n ws?.close();\n } catch {}\n status.push({\n relay: url,\n ok: count > 0,\n reason: reason ?? 'timeout',\n events: count,\n });\n resolve();\n }, timeoutMs);\n try {\n ws = new WebSocket(url);\n ws.onopen = () => ws?.send(JSON.stringify(['REQ', subId, filter]));\n ws.onmessage = (msg) => {\n const frame = parseFrame(msg.data as string);\n if (!frame) return;\n if (frame.type === 'EVENT' && frame.payload[0] === subId) {\n const event = frame.payload[1] as NostrEvent | undefined;\n if (event && event.id) {\n byId.set(event.id, event);\n count++;\n }\n } else if (frame.type === 'EOSE' && frame.payload[0] === subId) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n try {\n ws?.send(JSON.stringify(['CLOSE', subId]));\n ws?.close();\n } catch {}\n status.push({ relay: url, ok: true, events: count });\n resolve();\n } else if (frame.type === 'NOTICE') {\n reason = String(frame.payload[0] ?? 'notice');\n }\n };\n ws.onerror = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n status.push({\n relay: url,\n ok: false,\n reason: 'ws_error',\n events: count,\n });\n resolve();\n };\n ws.onclose = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n status.push({\n relay: url,\n ok: count > 0,\n reason: reason ?? 'closed_early',\n events: count,\n });\n resolve();\n };\n } catch (err) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n status.push({\n relay: url,\n ok: false,\n reason: err instanceof Error ? err.message : 'unknown',\n events: count,\n });\n resolve();\n }\n })\n )\n );\n\n return {\n events: Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at),\n relayStatus: status,\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @orangecheck/nostr-core\n *\n * Browser-compatible Nostr client used by every OrangeCheck family web\n * app. Raw NIP-01 over WebSocket against a list of relays. Every operation\n * races all relays in parallel and reports per-relay status so the caller\n * can distinguish \"nobody replied\" from \"one relay rejected.\" Retries with\n * exponential backoff on transport errors only.\n *\n * No dependencies — uses the platform `WebSocket` global. Works in any\n * runtime that ships a WHATWG WebSocket (browser, Node 22+, Deno, Bun,\n * Cloudflare Workers).\n *\n * Source-of-truth `DEFAULT_RELAYS` for the OC family. Co-publishes to four\n * public relays plus `wss://relay.ochk.io` (the family's first-party\n * kind-allowlisted relay — see https://github.com/orangecheck/oc-relay-infra).\n *\n * **Hard invariant:** `DEFAULT_RELAYS` MUST contain at least two entries,\n * and MUST NOT be `relay.ochk.io` alone. Enforced at the type level — a\n * future engineer simplifying to ours-only fails `tsc`. See `_validate`\n * below.\n */\n\n// ─────────────────────────────────────────────────────────────────────────\n// Build-time invariants + default relay set.\n// ─────────────────────────────────────────────────────────────────────────\n\n/**\n * Family-relay invariants applied to `DEFAULT_RELAYS`. The relay set must:\n * 1. Contain at least two relays — single-relay defaults are always wrong\n * because the family's BYPASS principle requires public-relay co-publish.\n * 2. Not be `wss://relay.ochk.io` alone — relay.ochk.io is additive, never\n * a single point of failure. See oc-relay-infra/BYPASS.md.\n *\n * If `T` violates either rule, this resolves to `never` and the assignment\n * below fails at `tsc` time.\n */\ntype ValidRelaySet<T extends readonly string[]> =\n T['length'] extends 0 | 1 ? never :\n T extends readonly ['wss://relay.ochk.io'] ? never :\n T;\n\nconst _RELAYS: ValidRelaySet<readonly [\n 'wss://nos.lol',\n 'wss://relay.primal.net',\n 'wss://offchain.pub',\n 'wss://relay.snort.social',\n 'wss://relay.ochk.io',\n]> = [\n 'wss://nos.lol',\n 'wss://relay.primal.net',\n 'wss://offchain.pub',\n // relay.nostr.band sat here until 2026-09-03, when it stopped accepting\n // TCP connections altogether (DNS resolves, port 443 never handshakes, and\n // the apex nostr.band is down too — from two independent networks).\n // relay.snort.social replaces it to keep the breadth this set exists for;\n // it answered every probe. Note it ACCEPTS unindexed tag filters by\n // ignoring them rather than rejecting, so it will happily return a flood of\n // unrelated events for a multi-letter filter — which the Filter type in\n // this file now makes impossible to express.\n 'wss://relay.snort.social',\n // First-party family relay — kind allowlist 30078-30087 + 30110-30114 + canonical\n // OC d-tag prefixes. Always co-published with public relays; never\n // the only copy. See https://github.com/orangecheck/oc-relay-infra.\n 'wss://relay.ochk.io',\n] as const;\n\n/**\n * Default relay set for OrangeCheck family Nostr publishes + queries.\n *\n * Frozen at runtime; consumers MAY pass an explicit `relays` arg to any\n * function in this package to override.\n *\n * @example\n * ```ts\n * import { DEFAULT_RELAYS, publishEvent } from '@orangecheck/nostr-core';\n *\n * const results = await publishEvent(myEvent);\n * // → publishes to all 5 relays in DEFAULT_RELAYS in parallel\n * console.log(`accepted on ${results.filter(r => r.ok).length}/${results.length}`);\n * ```\n */\nexport const DEFAULT_RELAYS: readonly string[] = Object.freeze([..._RELAYS]);\n\n// ─────────────────────────────────────────────────────────────────────────\n// Wire types — NIP-01 event + filter + result shapes.\n// ─────────────────────────────────────────────────────────────────────────\n\nexport interface NostrEvent {\n id: string;\n kind: number;\n pubkey: string;\n created_at: number;\n content: string;\n tags: string[][];\n sig: string;\n}\n\nexport interface PublishResult {\n relay: string;\n ok: boolean;\n reason?: string;\n attempts: number;\n}\n\n/**\n * The tag names a relay is required to index.\n *\n * NIP-12 defines indexed tag filters over **single-letter** names only. A\n * filter on a multi-letter name (`#poll_id`, `#delegation`, `#address`) is\n * accepted by the wire format, forwarded, and then matches nothing on a\n * conforming relay — it fails silently and returns an empty set.\n *\n * This type used to name `'#poll_id'`, `'#voter'` and `'#creator'` as\n * legitimate filters (\"Used by OC Vote\"), with a `#${string}` catch-all that\n * permitted any other. That is how the same bug reached four codebases:\n * OC Vote's tally found zero ballots for every poll, OC Agent's revocation\n * lookup found zero revocations and therefore honoured revoked delegations,\n * and `@orangecheck/sdk`'s `check()` found zero attestations. Every one of\n * them was a filter the type system had blessed.\n *\n * Restricting the key space to single letters turns that class of bug into a\n * compile error. If you need to query by some value, the event has to carry\n * it in a single-letter tag — most often `t` — and that is a fact about\n * Nostr, not a preference.\n */\ntype IndexableTagName =\n | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm'\n | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z'\n | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M'\n | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z';\n\n/** `#a` … `#Z` — the only tag filters a relay must honour. */\nexport type IndexableTagFilter = `#${IndexableTagName}`;\n\nexport interface Filter extends Partial<Record<IndexableTagFilter, string[]>> {\n kinds?: number[];\n authors?: string[];\n ids?: string[];\n limit?: number;\n since?: number;\n until?: number;\n}\n\nexport interface QueryResult {\n events: NostrEvent[];\n relayStatus: { relay: string; ok: boolean; reason?: string; events: number }[];\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Internal — frame parsing + retry config.\n// ─────────────────────────────────────────────────────────────────────────\n\ntype FrameType = 'OK' | 'EVENT' | 'EOSE' | 'NOTICE' | 'CLOSED' | 'AUTH';\ninterface RelayFrame {\n type: FrameType;\n payload: unknown[];\n}\n\n/**\n * Sign a [NIP-42](https://nips.nostr.com/42) AUTH challenge. Given the relay's\n * `challenge` string and `relayUrl`, return a signed kind-22242 event (tags\n * `[\"relay\", relayUrl]` + `[\"challenge\", challenge]`). Passed in by the caller so\n * this package stays dependency-free (no crypto here). Provide it only for\n * `auth-required` relays; absent it, an AUTH challenge is ignored (today's behavior).\n */\nexport type AuthSigner = (\n challenge: string,\n relayUrl: string\n) => NostrEvent | Promise<NostrEvent>;\n\nfunction parseFrame(raw: string): RelayFrame | null {\n try {\n const arr = JSON.parse(raw) as unknown[];\n if (!Array.isArray(arr) || arr.length === 0) return null;\n const type = arr[0];\n if (\n type === 'OK' ||\n type === 'EVENT' ||\n type === 'EOSE' ||\n type === 'NOTICE' ||\n type === 'CLOSED' ||\n type === 'AUTH'\n ) {\n return { type: type as FrameType, payload: arr.slice(1) };\n }\n return null;\n } catch {\n return null;\n }\n}\n\ninterface RetryOptions {\n attempts: number;\n timeoutMs: number;\n initialBackoffMs: number;\n maxBackoffMs: number;\n}\n\nconst DEFAULT_RETRY: RetryOptions = {\n attempts: 3,\n timeoutMs: 5000,\n initialBackoffMs: 500,\n maxBackoffMs: 4000,\n};\n\nfunction delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Publish — write an event to one or more relays in parallel, with retry.\n// ─────────────────────────────────────────────────────────────────────────\n\nasync function publishOne(\n url: string,\n event: NostrEvent,\n retry: RetryOptions,\n auth?: AuthSigner\n): Promise<PublishResult> {\n let attempts = 0;\n let backoff = retry.initialBackoffMs;\n let lastReason: string | undefined;\n\n while (attempts < retry.attempts) {\n attempts++;\n const attempt = await attemptPublish(url, event, retry.timeoutMs, auth);\n if (attempt.ok) {\n return {\n relay: url,\n ok: true,\n attempts,\n ...(attempt.reason ? { reason: attempt.reason } : {}),\n };\n }\n lastReason = attempt.reason;\n if (attempt.retryable && attempts < retry.attempts) {\n await delay(backoff);\n backoff = Math.min(backoff * 2, retry.maxBackoffMs);\n continue;\n }\n break;\n }\n return {\n relay: url,\n ok: false,\n attempts,\n ...(lastReason ? { reason: lastReason } : {}),\n };\n}\n\nfunction attemptPublish(\n url: string,\n event: NostrEvent,\n timeoutMs: number,\n auth?: AuthSigner\n): Promise<{ ok: boolean; reason?: string; retryable: boolean }> {\n return new Promise((resolve) => {\n let settled = false;\n let authed = false; // a NIP-42 AUTH handshake is attempted at most once\n let ws: WebSocket | null = null;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try {\n ws?.close();\n } catch {}\n resolve({ ok: false, reason: 'timeout', retryable: true });\n }, timeoutMs);\n try {\n ws = new WebSocket(url);\n ws.onopen = () => ws?.send(JSON.stringify(['EVENT', event]));\n ws.onmessage = (msg) => {\n const frame = parseFrame(msg.data as string);\n if (!frame) return;\n // NIP-42: an auth-required relay challenges before it accepts the\n // EVENT. Sign the challenge with the caller's signer, send AUTH,\n // then re-send the EVENT. Without an `auth` signer this is ignored.\n if (frame.type === 'AUTH' && auth && !authed) {\n authed = true;\n const challenge = String(frame.payload[0] ?? '');\n void Promise.resolve(auth(challenge, url))\n .then((authEvent) => {\n ws?.send(JSON.stringify(['AUTH', authEvent]));\n ws?.send(JSON.stringify(['EVENT', event]));\n })\n .catch(() => {\n /* signer failed → let the attempt time out / fail */\n });\n return;\n }\n if (frame.type === 'OK' && frame.payload[0] === event.id) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n try {\n ws?.close();\n } catch {}\n const ok = frame.payload[1] === true;\n const reason = frame.payload[2] as string | undefined;\n resolve({\n ok,\n ...(reason ? { reason } : {}),\n retryable: false,\n });\n }\n };\n ws.onerror = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n resolve({ ok: false, reason: 'websocket_error', retryable: true });\n };\n ws.onclose = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n resolve({ ok: false, reason: 'closed_early', retryable: true });\n };\n } catch (err) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n resolve({\n ok: false,\n reason: err instanceof Error ? err.message : 'unknown',\n retryable: true,\n });\n }\n });\n}\n\n/**\n * Publish a NIP-01 event to all `relays` in parallel. Returns one\n * `PublishResult` per relay. Default timeout 5000ms.\n */\nexport async function publishEvent(\n event: NostrEvent,\n relays: readonly string[] = DEFAULT_RELAYS,\n timeoutMs = 5000,\n auth?: AuthSigner\n): Promise<PublishResult[]> {\n const retry: RetryOptions = { ...DEFAULT_RETRY, timeoutMs };\n return Promise.all(relays.map((r) => publishOne(r, event, retry, auth)));\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Query — REQ → EOSE → close. Races all relays; first to EOSE wins.\n// ─────────────────────────────────────────────────────────────────────────\n\n/**\n * Issue a NIP-01 REQ across all `relays` in parallel. Returns deduplicated\n * events sorted newest-first plus per-relay status.\n *\n * Default timeout 1500ms — short enough that a momentary blip on any one\n * relay (including relay.ochk.io) never holds up the racing reads. Pass an\n * explicit `timeoutMs` for slow filters or for use cases where waiting on\n * the slowest relay matters.\n */\nexport async function queryEvents(\n filter: Filter,\n relays: readonly string[] = DEFAULT_RELAYS,\n timeoutMs = 1500,\n auth?: AuthSigner\n): Promise<QueryResult> {\n const subId = 'ocnc-' + Math.random().toString(36).slice(2, 10);\n const byId = new Map<string, NostrEvent>();\n const status: QueryResult['relayStatus'] = [];\n\n await Promise.all(\n relays.map(\n (url) =>\n new Promise<void>((resolve) => {\n let settled = false;\n let authed = false; // NIP-42 handshake attempted at most once\n let count = 0;\n let reason: string | undefined;\n let ws: WebSocket | null = null;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try {\n ws?.close();\n } catch {}\n status.push({\n relay: url,\n ok: count > 0,\n reason: reason ?? 'timeout',\n events: count,\n });\n resolve();\n }, timeoutMs);\n try {\n ws = new WebSocket(url);\n ws.onopen = () => ws?.send(JSON.stringify(['REQ', subId, filter]));\n ws.onmessage = (msg) => {\n const frame = parseFrame(msg.data as string);\n if (!frame) return;\n // NIP-42: auth-required relay challenges before serving the\n // REQ — sign, send AUTH, then re-send the REQ. Ignored absent\n // an `auth` signer (today's behavior).\n if (frame.type === 'AUTH' && auth && !authed) {\n authed = true;\n const challenge = String(frame.payload[0] ?? '');\n void Promise.resolve(auth(challenge, url))\n .then((authEvent) => {\n ws?.send(JSON.stringify(['AUTH', authEvent]));\n ws?.send(JSON.stringify(['REQ', subId, filter]));\n })\n .catch(() => {\n /* signer failed → let the query time out */\n });\n return;\n }\n if (frame.type === 'EVENT' && frame.payload[0] === subId) {\n const event = frame.payload[1] as NostrEvent | undefined;\n if (event && event.id) {\n byId.set(event.id, event);\n count++;\n }\n } else if (frame.type === 'EOSE' && frame.payload[0] === subId) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n try {\n ws?.send(JSON.stringify(['CLOSE', subId]));\n ws?.close();\n } catch {}\n status.push({ relay: url, ok: true, events: count });\n resolve();\n } else if (frame.type === 'NOTICE') {\n reason = String(frame.payload[0] ?? 'notice');\n }\n };\n ws.onerror = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n status.push({\n relay: url,\n ok: false,\n reason: 'ws_error',\n events: count,\n });\n resolve();\n };\n ws.onclose = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n status.push({\n relay: url,\n ok: count > 0,\n reason: reason ?? 'closed_early',\n events: count,\n });\n resolve();\n };\n } catch (err) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n status.push({\n relay: url,\n ok: false,\n reason: err instanceof Error ? err.message : 'unknown',\n events: count,\n });\n resolve();\n }\n })\n )\n );\n\n return {\n events: Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at),\n relayStatus: status,\n };\n}\n"]}
package/dist/index.mjs CHANGED
@@ -1,10 +1,18 @@
1
1
  // src/index.ts
2
2
  var _RELAYS = [
3
- "wss://relay.nostr.band",
4
3
  "wss://nos.lol",
5
4
  "wss://relay.primal.net",
6
5
  "wss://offchain.pub",
7
- // First-party family relay kind allowlist 30078–30086 + canonical
6
+ // relay.nostr.band sat here until 2026-09-03, when it stopped accepting
7
+ // TCP connections altogether (DNS resolves, port 443 never handshakes, and
8
+ // the apex nostr.band is down too — from two independent networks).
9
+ // relay.snort.social replaces it to keep the breadth this set exists for;
10
+ // it answered every probe. Note it ACCEPTS unindexed tag filters by
11
+ // ignoring them rather than rejecting, so it will happily return a flood of
12
+ // unrelated events for a multi-letter filter — which the Filter type in
13
+ // this file now makes impossible to express.
14
+ "wss://relay.snort.social",
15
+ // First-party family relay — kind allowlist 30078-30087 + 30110-30114 + canonical
8
16
  // OC d-tag prefixes. Always co-published with public relays; never
9
17
  // the only copy. See https://github.com/orangecheck/oc-relay-infra.
10
18
  "wss://relay.ochk.io"
@@ -15,7 +23,7 @@ function parseFrame(raw) {
15
23
  const arr = JSON.parse(raw);
16
24
  if (!Array.isArray(arr) || arr.length === 0) return null;
17
25
  const type = arr[0];
18
- if (type === "OK" || type === "EVENT" || type === "EOSE" || type === "NOTICE" || type === "CLOSED") {
26
+ if (type === "OK" || type === "EVENT" || type === "EOSE" || type === "NOTICE" || type === "CLOSED" || type === "AUTH") {
19
27
  return { type, payload: arr.slice(1) };
20
28
  }
21
29
  return null;
@@ -32,13 +40,13 @@ var DEFAULT_RETRY = {
32
40
  function delay(ms) {
33
41
  return new Promise((resolve) => setTimeout(resolve, ms));
34
42
  }
35
- async function publishOne(url, event, retry) {
43
+ async function publishOne(url, event, retry, auth) {
36
44
  let attempts = 0;
37
45
  let backoff = retry.initialBackoffMs;
38
46
  let lastReason;
39
47
  while (attempts < retry.attempts) {
40
48
  attempts++;
41
- const attempt = await attemptPublish(url, event, retry.timeoutMs);
49
+ const attempt = await attemptPublish(url, event, retry.timeoutMs, auth);
42
50
  if (attempt.ok) {
43
51
  return {
44
52
  relay: url,
@@ -62,9 +70,10 @@ async function publishOne(url, event, retry) {
62
70
  ...lastReason ? { reason: lastReason } : {}
63
71
  };
64
72
  }
65
- function attemptPublish(url, event, timeoutMs) {
73
+ function attemptPublish(url, event, timeoutMs, auth) {
66
74
  return new Promise((resolve) => {
67
75
  let settled = false;
76
+ let authed = false;
68
77
  let ws = null;
69
78
  const timer = setTimeout(() => {
70
79
  if (settled) return;
@@ -81,6 +90,16 @@ function attemptPublish(url, event, timeoutMs) {
81
90
  ws.onmessage = (msg) => {
82
91
  const frame = parseFrame(msg.data);
83
92
  if (!frame) return;
93
+ if (frame.type === "AUTH" && auth && !authed) {
94
+ authed = true;
95
+ const challenge = String(frame.payload[0] ?? "");
96
+ void Promise.resolve(auth(challenge, url)).then((authEvent) => {
97
+ ws?.send(JSON.stringify(["AUTH", authEvent]));
98
+ ws?.send(JSON.stringify(["EVENT", event]));
99
+ }).catch(() => {
100
+ });
101
+ return;
102
+ }
84
103
  if (frame.type === "OK" && frame.payload[0] === event.id) {
85
104
  if (settled) return;
86
105
  settled = true;
@@ -122,11 +141,11 @@ function attemptPublish(url, event, timeoutMs) {
122
141
  }
123
142
  });
124
143
  }
125
- async function publishEvent(event, relays = DEFAULT_RELAYS, timeoutMs = 5e3) {
144
+ async function publishEvent(event, relays = DEFAULT_RELAYS, timeoutMs = 5e3, auth) {
126
145
  const retry = { ...DEFAULT_RETRY, timeoutMs };
127
- return Promise.all(relays.map((r) => publishOne(r, event, retry)));
146
+ return Promise.all(relays.map((r) => publishOne(r, event, retry, auth)));
128
147
  }
129
- async function queryEvents(filter, relays = DEFAULT_RELAYS, timeoutMs = 1500) {
148
+ async function queryEvents(filter, relays = DEFAULT_RELAYS, timeoutMs = 1500, auth) {
130
149
  const subId = "ocnc-" + Math.random().toString(36).slice(2, 10);
131
150
  const byId = /* @__PURE__ */ new Map();
132
151
  const status = [];
@@ -134,6 +153,7 @@ async function queryEvents(filter, relays = DEFAULT_RELAYS, timeoutMs = 1500) {
134
153
  relays.map(
135
154
  (url) => new Promise((resolve) => {
136
155
  let settled = false;
156
+ let authed = false;
137
157
  let count = 0;
138
158
  let reason;
139
159
  let ws = null;
@@ -158,6 +178,16 @@ async function queryEvents(filter, relays = DEFAULT_RELAYS, timeoutMs = 1500) {
158
178
  ws.onmessage = (msg) => {
159
179
  const frame = parseFrame(msg.data);
160
180
  if (!frame) return;
181
+ if (frame.type === "AUTH" && auth && !authed) {
182
+ authed = true;
183
+ const challenge = String(frame.payload[0] ?? "");
184
+ void Promise.resolve(auth(challenge, url)).then((authEvent) => {
185
+ ws?.send(JSON.stringify(["AUTH", authEvent]));
186
+ ws?.send(JSON.stringify(["REQ", subId, filter]));
187
+ }).catch(() => {
188
+ });
189
+ return;
190
+ }
161
191
  if (frame.type === "EVENT" && frame.payload[0] === subId) {
162
192
  const event = frame.payload[1];
163
193
  if (event && event.id) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["/**\n * @orangecheck/nostr-core\n *\n * Browser-compatible Nostr client used by every OrangeCheck family web\n * app. Raw NIP-01 over WebSocket against a list of relays. Every operation\n * races all relays in parallel and reports per-relay status so the caller\n * can distinguish \"nobody replied\" from \"one relay rejected.\" Retries with\n * exponential backoff on transport errors only.\n *\n * No dependencies — uses the platform `WebSocket` global. Works in any\n * runtime that ships a WHATWG WebSocket (browser, Node 22+, Deno, Bun,\n * Cloudflare Workers).\n *\n * Source-of-truth `DEFAULT_RELAYS` for the OC family. Co-publishes to four\n * public relays plus `wss://relay.ochk.io` (the family's first-party\n * kind-allowlisted relay — see https://github.com/orangecheck/oc-relay-infra).\n *\n * **Hard invariant:** `DEFAULT_RELAYS` MUST contain at least two entries,\n * and MUST NOT be `relay.ochk.io` alone. Enforced at the type level — a\n * future engineer simplifying to ours-only fails `tsc`. See `_validate`\n * below.\n */\n\n// ─────────────────────────────────────────────────────────────────────────\n// Build-time invariants + default relay set.\n// ─────────────────────────────────────────────────────────────────────────\n\n/**\n * Family-relay invariants applied to `DEFAULT_RELAYS`. The relay set must:\n * 1. Contain at least two relays — single-relay defaults are always wrong\n * because the family's BYPASS principle requires public-relay co-publish.\n * 2. Not be `wss://relay.ochk.io` alone — relay.ochk.io is additive, never\n * a single point of failure. See oc-relay-infra/BYPASS.md.\n *\n * If `T` violates either rule, this resolves to `never` and the assignment\n * below fails at `tsc` time.\n */\ntype ValidRelaySet<T extends readonly string[]> =\n T['length'] extends 0 | 1 ? never :\n T extends readonly ['wss://relay.ochk.io'] ? never :\n T;\n\nconst _RELAYS: ValidRelaySet<readonly [\n 'wss://relay.nostr.band',\n 'wss://nos.lol',\n 'wss://relay.primal.net',\n 'wss://offchain.pub',\n 'wss://relay.ochk.io',\n]> = [\n 'wss://relay.nostr.band',\n 'wss://nos.lol',\n 'wss://relay.primal.net',\n 'wss://offchain.pub',\n // First-party family relay — kind allowlist 30078–30086 + canonical\n // OC d-tag prefixes. Always co-published with public relays; never\n // the only copy. See https://github.com/orangecheck/oc-relay-infra.\n 'wss://relay.ochk.io',\n] as const;\n\n/**\n * Default relay set for OrangeCheck family Nostr publishes + queries.\n *\n * Frozen at runtime; consumers MAY pass an explicit `relays` arg to any\n * function in this package to override.\n *\n * @example\n * ```ts\n * import { DEFAULT_RELAYS, publishEvent } from '@orangecheck/nostr-core';\n *\n * const results = await publishEvent(myEvent);\n * // → publishes to all 5 relays in DEFAULT_RELAYS in parallel\n * console.log(`accepted on ${results.filter(r => r.ok).length}/${results.length}`);\n * ```\n */\nexport const DEFAULT_RELAYS: readonly string[] = Object.freeze([..._RELAYS]);\n\n// ─────────────────────────────────────────────────────────────────────────\n// Wire types — NIP-01 event + filter + result shapes.\n// ─────────────────────────────────────────────────────────────────────────\n\nexport interface NostrEvent {\n id: string;\n kind: number;\n pubkey: string;\n created_at: number;\n content: string;\n tags: string[][];\n sig: string;\n}\n\nexport interface PublishResult {\n relay: string;\n ok: boolean;\n reason?: string;\n attempts: number;\n}\n\nexport interface Filter {\n kinds?: number[];\n authors?: string[];\n ids?: string[];\n limit?: number;\n since?: number;\n until?: number;\n /** NIP-12 indexable `d`-tag filter. */\n '#d'?: string[];\n /** NIP-12 indexable single-letter tag filter. */\n '#t'?: string[];\n /** Used by OC Vote (kind 30081 ballots). */\n '#poll_id'?: string[];\n /** Used by OC Vote (kind 30081 ballots). */\n '#voter'?: string[];\n /** Used by OC Vote (kind 30080 polls). */\n '#creator'?: string[];\n /** Other indexable single-letter tags clients may filter on. */\n [key: `#${string}`]: string[] | undefined;\n}\n\nexport interface QueryResult {\n events: NostrEvent[];\n relayStatus: { relay: string; ok: boolean; reason?: string; events: number }[];\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Internal — frame parsing + retry config.\n// ─────────────────────────────────────────────────────────────────────────\n\ntype FrameType = 'OK' | 'EVENT' | 'EOSE' | 'NOTICE' | 'CLOSED';\ninterface RelayFrame {\n type: FrameType;\n payload: unknown[];\n}\n\nfunction parseFrame(raw: string): RelayFrame | null {\n try {\n const arr = JSON.parse(raw) as unknown[];\n if (!Array.isArray(arr) || arr.length === 0) return null;\n const type = arr[0];\n if (\n type === 'OK' ||\n type === 'EVENT' ||\n type === 'EOSE' ||\n type === 'NOTICE' ||\n type === 'CLOSED'\n ) {\n return { type: type as FrameType, payload: arr.slice(1) };\n }\n return null;\n } catch {\n return null;\n }\n}\n\ninterface RetryOptions {\n attempts: number;\n timeoutMs: number;\n initialBackoffMs: number;\n maxBackoffMs: number;\n}\n\nconst DEFAULT_RETRY: RetryOptions = {\n attempts: 3,\n timeoutMs: 5000,\n initialBackoffMs: 500,\n maxBackoffMs: 4000,\n};\n\nfunction delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Publish — write an event to one or more relays in parallel, with retry.\n// ─────────────────────────────────────────────────────────────────────────\n\nasync function publishOne(\n url: string,\n event: NostrEvent,\n retry: RetryOptions\n): Promise<PublishResult> {\n let attempts = 0;\n let backoff = retry.initialBackoffMs;\n let lastReason: string | undefined;\n\n while (attempts < retry.attempts) {\n attempts++;\n const attempt = await attemptPublish(url, event, retry.timeoutMs);\n if (attempt.ok) {\n return {\n relay: url,\n ok: true,\n attempts,\n ...(attempt.reason ? { reason: attempt.reason } : {}),\n };\n }\n lastReason = attempt.reason;\n if (attempt.retryable && attempts < retry.attempts) {\n await delay(backoff);\n backoff = Math.min(backoff * 2, retry.maxBackoffMs);\n continue;\n }\n break;\n }\n return {\n relay: url,\n ok: false,\n attempts,\n ...(lastReason ? { reason: lastReason } : {}),\n };\n}\n\nfunction attemptPublish(\n url: string,\n event: NostrEvent,\n timeoutMs: number\n): Promise<{ ok: boolean; reason?: string; retryable: boolean }> {\n return new Promise((resolve) => {\n let settled = false;\n let ws: WebSocket | null = null;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try {\n ws?.close();\n } catch {}\n resolve({ ok: false, reason: 'timeout', retryable: true });\n }, timeoutMs);\n try {\n ws = new WebSocket(url);\n ws.onopen = () => ws?.send(JSON.stringify(['EVENT', event]));\n ws.onmessage = (msg) => {\n const frame = parseFrame(msg.data as string);\n if (!frame) return;\n if (frame.type === 'OK' && frame.payload[0] === event.id) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n try {\n ws?.close();\n } catch {}\n const ok = frame.payload[1] === true;\n const reason = frame.payload[2] as string | undefined;\n resolve({\n ok,\n ...(reason ? { reason } : {}),\n retryable: false,\n });\n }\n };\n ws.onerror = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n resolve({ ok: false, reason: 'websocket_error', retryable: true });\n };\n ws.onclose = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n resolve({ ok: false, reason: 'closed_early', retryable: true });\n };\n } catch (err) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n resolve({\n ok: false,\n reason: err instanceof Error ? err.message : 'unknown',\n retryable: true,\n });\n }\n });\n}\n\n/**\n * Publish a NIP-01 event to all `relays` in parallel. Returns one\n * `PublishResult` per relay. Default timeout 5000ms.\n */\nexport async function publishEvent(\n event: NostrEvent,\n relays: readonly string[] = DEFAULT_RELAYS,\n timeoutMs = 5000\n): Promise<PublishResult[]> {\n const retry: RetryOptions = { ...DEFAULT_RETRY, timeoutMs };\n return Promise.all(relays.map((r) => publishOne(r, event, retry)));\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Query — REQ → EOSE → close. Races all relays; first to EOSE wins.\n// ─────────────────────────────────────────────────────────────────────────\n\n/**\n * Issue a NIP-01 REQ across all `relays` in parallel. Returns deduplicated\n * events sorted newest-first plus per-relay status.\n *\n * Default timeout 1500ms — short enough that a momentary blip on any one\n * relay (including relay.ochk.io) never holds up the racing reads. Pass an\n * explicit `timeoutMs` for slow filters or for use cases where waiting on\n * the slowest relay matters.\n */\nexport async function queryEvents(\n filter: Filter,\n relays: readonly string[] = DEFAULT_RELAYS,\n timeoutMs = 1500\n): Promise<QueryResult> {\n const subId = 'ocnc-' + Math.random().toString(36).slice(2, 10);\n const byId = new Map<string, NostrEvent>();\n const status: QueryResult['relayStatus'] = [];\n\n await Promise.all(\n relays.map(\n (url) =>\n new Promise<void>((resolve) => {\n let settled = false;\n let count = 0;\n let reason: string | undefined;\n let ws: WebSocket | null = null;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try {\n ws?.close();\n } catch {}\n status.push({\n relay: url,\n ok: count > 0,\n reason: reason ?? 'timeout',\n events: count,\n });\n resolve();\n }, timeoutMs);\n try {\n ws = new WebSocket(url);\n ws.onopen = () => ws?.send(JSON.stringify(['REQ', subId, filter]));\n ws.onmessage = (msg) => {\n const frame = parseFrame(msg.data as string);\n if (!frame) return;\n if (frame.type === 'EVENT' && frame.payload[0] === subId) {\n const event = frame.payload[1] as NostrEvent | undefined;\n if (event && event.id) {\n byId.set(event.id, event);\n count++;\n }\n } else if (frame.type === 'EOSE' && frame.payload[0] === subId) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n try {\n ws?.send(JSON.stringify(['CLOSE', subId]));\n ws?.close();\n } catch {}\n status.push({ relay: url, ok: true, events: count });\n resolve();\n } else if (frame.type === 'NOTICE') {\n reason = String(frame.payload[0] ?? 'notice');\n }\n };\n ws.onerror = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n status.push({\n relay: url,\n ok: false,\n reason: 'ws_error',\n events: count,\n });\n resolve();\n };\n ws.onclose = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n status.push({\n relay: url,\n ok: count > 0,\n reason: reason ?? 'closed_early',\n events: count,\n });\n resolve();\n };\n } catch (err) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n status.push({\n relay: url,\n ok: false,\n reason: err instanceof Error ? err.message : 'unknown',\n events: count,\n });\n resolve();\n }\n })\n )\n );\n\n return {\n events: Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at),\n relayStatus: status,\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["/**\n * @orangecheck/nostr-core\n *\n * Browser-compatible Nostr client used by every OrangeCheck family web\n * app. Raw NIP-01 over WebSocket against a list of relays. Every operation\n * races all relays in parallel and reports per-relay status so the caller\n * can distinguish \"nobody replied\" from \"one relay rejected.\" Retries with\n * exponential backoff on transport errors only.\n *\n * No dependencies — uses the platform `WebSocket` global. Works in any\n * runtime that ships a WHATWG WebSocket (browser, Node 22+, Deno, Bun,\n * Cloudflare Workers).\n *\n * Source-of-truth `DEFAULT_RELAYS` for the OC family. Co-publishes to four\n * public relays plus `wss://relay.ochk.io` (the family's first-party\n * kind-allowlisted relay — see https://github.com/orangecheck/oc-relay-infra).\n *\n * **Hard invariant:** `DEFAULT_RELAYS` MUST contain at least two entries,\n * and MUST NOT be `relay.ochk.io` alone. Enforced at the type level — a\n * future engineer simplifying to ours-only fails `tsc`. See `_validate`\n * below.\n */\n\n// ─────────────────────────────────────────────────────────────────────────\n// Build-time invariants + default relay set.\n// ─────────────────────────────────────────────────────────────────────────\n\n/**\n * Family-relay invariants applied to `DEFAULT_RELAYS`. The relay set must:\n * 1. Contain at least two relays — single-relay defaults are always wrong\n * because the family's BYPASS principle requires public-relay co-publish.\n * 2. Not be `wss://relay.ochk.io` alone — relay.ochk.io is additive, never\n * a single point of failure. See oc-relay-infra/BYPASS.md.\n *\n * If `T` violates either rule, this resolves to `never` and the assignment\n * below fails at `tsc` time.\n */\ntype ValidRelaySet<T extends readonly string[]> =\n T['length'] extends 0 | 1 ? never :\n T extends readonly ['wss://relay.ochk.io'] ? never :\n T;\n\nconst _RELAYS: ValidRelaySet<readonly [\n 'wss://nos.lol',\n 'wss://relay.primal.net',\n 'wss://offchain.pub',\n 'wss://relay.snort.social',\n 'wss://relay.ochk.io',\n]> = [\n 'wss://nos.lol',\n 'wss://relay.primal.net',\n 'wss://offchain.pub',\n // relay.nostr.band sat here until 2026-09-03, when it stopped accepting\n // TCP connections altogether (DNS resolves, port 443 never handshakes, and\n // the apex nostr.band is down too — from two independent networks).\n // relay.snort.social replaces it to keep the breadth this set exists for;\n // it answered every probe. Note it ACCEPTS unindexed tag filters by\n // ignoring them rather than rejecting, so it will happily return a flood of\n // unrelated events for a multi-letter filter — which the Filter type in\n // this file now makes impossible to express.\n 'wss://relay.snort.social',\n // First-party family relay — kind allowlist 30078-30087 + 30110-30114 + canonical\n // OC d-tag prefixes. Always co-published with public relays; never\n // the only copy. See https://github.com/orangecheck/oc-relay-infra.\n 'wss://relay.ochk.io',\n] as const;\n\n/**\n * Default relay set for OrangeCheck family Nostr publishes + queries.\n *\n * Frozen at runtime; consumers MAY pass an explicit `relays` arg to any\n * function in this package to override.\n *\n * @example\n * ```ts\n * import { DEFAULT_RELAYS, publishEvent } from '@orangecheck/nostr-core';\n *\n * const results = await publishEvent(myEvent);\n * // → publishes to all 5 relays in DEFAULT_RELAYS in parallel\n * console.log(`accepted on ${results.filter(r => r.ok).length}/${results.length}`);\n * ```\n */\nexport const DEFAULT_RELAYS: readonly string[] = Object.freeze([..._RELAYS]);\n\n// ─────────────────────────────────────────────────────────────────────────\n// Wire types — NIP-01 event + filter + result shapes.\n// ─────────────────────────────────────────────────────────────────────────\n\nexport interface NostrEvent {\n id: string;\n kind: number;\n pubkey: string;\n created_at: number;\n content: string;\n tags: string[][];\n sig: string;\n}\n\nexport interface PublishResult {\n relay: string;\n ok: boolean;\n reason?: string;\n attempts: number;\n}\n\n/**\n * The tag names a relay is required to index.\n *\n * NIP-12 defines indexed tag filters over **single-letter** names only. A\n * filter on a multi-letter name (`#poll_id`, `#delegation`, `#address`) is\n * accepted by the wire format, forwarded, and then matches nothing on a\n * conforming relay — it fails silently and returns an empty set.\n *\n * This type used to name `'#poll_id'`, `'#voter'` and `'#creator'` as\n * legitimate filters (\"Used by OC Vote\"), with a `#${string}` catch-all that\n * permitted any other. That is how the same bug reached four codebases:\n * OC Vote's tally found zero ballots for every poll, OC Agent's revocation\n * lookup found zero revocations and therefore honoured revoked delegations,\n * and `@orangecheck/sdk`'s `check()` found zero attestations. Every one of\n * them was a filter the type system had blessed.\n *\n * Restricting the key space to single letters turns that class of bug into a\n * compile error. If you need to query by some value, the event has to carry\n * it in a single-letter tag — most often `t` — and that is a fact about\n * Nostr, not a preference.\n */\ntype IndexableTagName =\n | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm'\n | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z'\n | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M'\n | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z';\n\n/** `#a` … `#Z` — the only tag filters a relay must honour. */\nexport type IndexableTagFilter = `#${IndexableTagName}`;\n\nexport interface Filter extends Partial<Record<IndexableTagFilter, string[]>> {\n kinds?: number[];\n authors?: string[];\n ids?: string[];\n limit?: number;\n since?: number;\n until?: number;\n}\n\nexport interface QueryResult {\n events: NostrEvent[];\n relayStatus: { relay: string; ok: boolean; reason?: string; events: number }[];\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Internal — frame parsing + retry config.\n// ─────────────────────────────────────────────────────────────────────────\n\ntype FrameType = 'OK' | 'EVENT' | 'EOSE' | 'NOTICE' | 'CLOSED' | 'AUTH';\ninterface RelayFrame {\n type: FrameType;\n payload: unknown[];\n}\n\n/**\n * Sign a [NIP-42](https://nips.nostr.com/42) AUTH challenge. Given the relay's\n * `challenge` string and `relayUrl`, return a signed kind-22242 event (tags\n * `[\"relay\", relayUrl]` + `[\"challenge\", challenge]`). Passed in by the caller so\n * this package stays dependency-free (no crypto here). Provide it only for\n * `auth-required` relays; absent it, an AUTH challenge is ignored (today's behavior).\n */\nexport type AuthSigner = (\n challenge: string,\n relayUrl: string\n) => NostrEvent | Promise<NostrEvent>;\n\nfunction parseFrame(raw: string): RelayFrame | null {\n try {\n const arr = JSON.parse(raw) as unknown[];\n if (!Array.isArray(arr) || arr.length === 0) return null;\n const type = arr[0];\n if (\n type === 'OK' ||\n type === 'EVENT' ||\n type === 'EOSE' ||\n type === 'NOTICE' ||\n type === 'CLOSED' ||\n type === 'AUTH'\n ) {\n return { type: type as FrameType, payload: arr.slice(1) };\n }\n return null;\n } catch {\n return null;\n }\n}\n\ninterface RetryOptions {\n attempts: number;\n timeoutMs: number;\n initialBackoffMs: number;\n maxBackoffMs: number;\n}\n\nconst DEFAULT_RETRY: RetryOptions = {\n attempts: 3,\n timeoutMs: 5000,\n initialBackoffMs: 500,\n maxBackoffMs: 4000,\n};\n\nfunction delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Publish — write an event to one or more relays in parallel, with retry.\n// ─────────────────────────────────────────────────────────────────────────\n\nasync function publishOne(\n url: string,\n event: NostrEvent,\n retry: RetryOptions,\n auth?: AuthSigner\n): Promise<PublishResult> {\n let attempts = 0;\n let backoff = retry.initialBackoffMs;\n let lastReason: string | undefined;\n\n while (attempts < retry.attempts) {\n attempts++;\n const attempt = await attemptPublish(url, event, retry.timeoutMs, auth);\n if (attempt.ok) {\n return {\n relay: url,\n ok: true,\n attempts,\n ...(attempt.reason ? { reason: attempt.reason } : {}),\n };\n }\n lastReason = attempt.reason;\n if (attempt.retryable && attempts < retry.attempts) {\n await delay(backoff);\n backoff = Math.min(backoff * 2, retry.maxBackoffMs);\n continue;\n }\n break;\n }\n return {\n relay: url,\n ok: false,\n attempts,\n ...(lastReason ? { reason: lastReason } : {}),\n };\n}\n\nfunction attemptPublish(\n url: string,\n event: NostrEvent,\n timeoutMs: number,\n auth?: AuthSigner\n): Promise<{ ok: boolean; reason?: string; retryable: boolean }> {\n return new Promise((resolve) => {\n let settled = false;\n let authed = false; // a NIP-42 AUTH handshake is attempted at most once\n let ws: WebSocket | null = null;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try {\n ws?.close();\n } catch {}\n resolve({ ok: false, reason: 'timeout', retryable: true });\n }, timeoutMs);\n try {\n ws = new WebSocket(url);\n ws.onopen = () => ws?.send(JSON.stringify(['EVENT', event]));\n ws.onmessage = (msg) => {\n const frame = parseFrame(msg.data as string);\n if (!frame) return;\n // NIP-42: an auth-required relay challenges before it accepts the\n // EVENT. Sign the challenge with the caller's signer, send AUTH,\n // then re-send the EVENT. Without an `auth` signer this is ignored.\n if (frame.type === 'AUTH' && auth && !authed) {\n authed = true;\n const challenge = String(frame.payload[0] ?? '');\n void Promise.resolve(auth(challenge, url))\n .then((authEvent) => {\n ws?.send(JSON.stringify(['AUTH', authEvent]));\n ws?.send(JSON.stringify(['EVENT', event]));\n })\n .catch(() => {\n /* signer failed → let the attempt time out / fail */\n });\n return;\n }\n if (frame.type === 'OK' && frame.payload[0] === event.id) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n try {\n ws?.close();\n } catch {}\n const ok = frame.payload[1] === true;\n const reason = frame.payload[2] as string | undefined;\n resolve({\n ok,\n ...(reason ? { reason } : {}),\n retryable: false,\n });\n }\n };\n ws.onerror = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n resolve({ ok: false, reason: 'websocket_error', retryable: true });\n };\n ws.onclose = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n resolve({ ok: false, reason: 'closed_early', retryable: true });\n };\n } catch (err) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n resolve({\n ok: false,\n reason: err instanceof Error ? err.message : 'unknown',\n retryable: true,\n });\n }\n });\n}\n\n/**\n * Publish a NIP-01 event to all `relays` in parallel. Returns one\n * `PublishResult` per relay. Default timeout 5000ms.\n */\nexport async function publishEvent(\n event: NostrEvent,\n relays: readonly string[] = DEFAULT_RELAYS,\n timeoutMs = 5000,\n auth?: AuthSigner\n): Promise<PublishResult[]> {\n const retry: RetryOptions = { ...DEFAULT_RETRY, timeoutMs };\n return Promise.all(relays.map((r) => publishOne(r, event, retry, auth)));\n}\n\n// ─────────────────────────────────────────────────────────────────────────\n// Query — REQ → EOSE → close. Races all relays; first to EOSE wins.\n// ─────────────────────────────────────────────────────────────────────────\n\n/**\n * Issue a NIP-01 REQ across all `relays` in parallel. Returns deduplicated\n * events sorted newest-first plus per-relay status.\n *\n * Default timeout 1500ms — short enough that a momentary blip on any one\n * relay (including relay.ochk.io) never holds up the racing reads. Pass an\n * explicit `timeoutMs` for slow filters or for use cases where waiting on\n * the slowest relay matters.\n */\nexport async function queryEvents(\n filter: Filter,\n relays: readonly string[] = DEFAULT_RELAYS,\n timeoutMs = 1500,\n auth?: AuthSigner\n): Promise<QueryResult> {\n const subId = 'ocnc-' + Math.random().toString(36).slice(2, 10);\n const byId = new Map<string, NostrEvent>();\n const status: QueryResult['relayStatus'] = [];\n\n await Promise.all(\n relays.map(\n (url) =>\n new Promise<void>((resolve) => {\n let settled = false;\n let authed = false; // NIP-42 handshake attempted at most once\n let count = 0;\n let reason: string | undefined;\n let ws: WebSocket | null = null;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try {\n ws?.close();\n } catch {}\n status.push({\n relay: url,\n ok: count > 0,\n reason: reason ?? 'timeout',\n events: count,\n });\n resolve();\n }, timeoutMs);\n try {\n ws = new WebSocket(url);\n ws.onopen = () => ws?.send(JSON.stringify(['REQ', subId, filter]));\n ws.onmessage = (msg) => {\n const frame = parseFrame(msg.data as string);\n if (!frame) return;\n // NIP-42: auth-required relay challenges before serving the\n // REQ — sign, send AUTH, then re-send the REQ. Ignored absent\n // an `auth` signer (today's behavior).\n if (frame.type === 'AUTH' && auth && !authed) {\n authed = true;\n const challenge = String(frame.payload[0] ?? '');\n void Promise.resolve(auth(challenge, url))\n .then((authEvent) => {\n ws?.send(JSON.stringify(['AUTH', authEvent]));\n ws?.send(JSON.stringify(['REQ', subId, filter]));\n })\n .catch(() => {\n /* signer failed → let the query time out */\n });\n return;\n }\n if (frame.type === 'EVENT' && frame.payload[0] === subId) {\n const event = frame.payload[1] as NostrEvent | undefined;\n if (event && event.id) {\n byId.set(event.id, event);\n count++;\n }\n } else if (frame.type === 'EOSE' && frame.payload[0] === subId) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n try {\n ws?.send(JSON.stringify(['CLOSE', subId]));\n ws?.close();\n } catch {}\n status.push({ relay: url, ok: true, events: count });\n resolve();\n } else if (frame.type === 'NOTICE') {\n reason = String(frame.payload[0] ?? 'notice');\n }\n };\n ws.onerror = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n status.push({\n relay: url,\n ok: false,\n reason: 'ws_error',\n events: count,\n });\n resolve();\n };\n ws.onclose = () => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n status.push({\n relay: url,\n ok: count > 0,\n reason: reason ?? 'closed_early',\n events: count,\n });\n resolve();\n };\n } catch (err) {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n status.push({\n relay: url,\n ok: false,\n reason: err instanceof Error ? err.message : 'unknown',\n events: count,\n });\n resolve();\n }\n })\n )\n );\n\n return {\n events: Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at),\n relayStatus: status,\n };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangecheck/nostr-core",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Browser-compatible Nostr client used by every OrangeCheck family web app. Raw NIP-01 over WebSocket against a list of relays. publishEvent / queryEvents / DEFAULT_RELAYS.",
5
5
  "keywords": [
6
6
  "nostr",
package/src/index.test.ts CHANGED
@@ -13,15 +13,34 @@
13
13
  */
14
14
 
15
15
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
- import { DEFAULT_RELAYS, publishEvent, queryEvents, type NostrEvent } from './index';
16
+ import {
17
+ DEFAULT_RELAYS,
18
+ publishEvent,
19
+ queryEvents,
20
+ type Filter,
21
+ type NostrEvent,
22
+ } from './index';
17
23
 
18
24
  // ── DEFAULT_RELAYS ───────────────────────────────────────────────────────
19
25
 
20
26
  describe('DEFAULT_RELAYS', () => {
21
27
  it('has at least 5 relays', () => {
28
+ // Breadth is the point, not the number: no artifact should depend on
29
+ // any single relay being up (invariant 5, and oc-relay-infra/BYPASS.md).
30
+ // This caught the 2026-09-03 removal of relay.nostr.band, which had
31
+ // gone permanently unreachable — the right response was to restore
32
+ // breadth with a relay that answers, not to lower the bar.
22
33
  expect(DEFAULT_RELAYS.length).toBeGreaterThanOrEqual(5);
23
34
  });
24
35
 
36
+ it('does not list a relay known to be unreachable', () => {
37
+ // relay.nostr.band: DNS resolves 95.216.33.150 but port 443 never
38
+ // completes a handshake, from two independent networks, and the apex
39
+ // nostr.band is down too. A dead relay in a default set is worse than a
40
+ // missing one — fan-outs wait on it. Re-add it only after checking.
41
+ expect(DEFAULT_RELAYS).not.toContain('wss://relay.nostr.band');
42
+ });
43
+
25
44
  it('includes the family first-party relay', () => {
26
45
  expect(DEFAULT_RELAYS).toContain('wss://relay.ochk.io');
27
46
  });
@@ -152,6 +171,33 @@ describe('queryEvents', () => {
152
171
  });
153
172
  });
154
173
 
174
+ describe('NIP-42 AUTH (additive)', () => {
175
+ it('publishEvent completes the AUTH handshake then publishes', async () => {
176
+ mockNextAuthPublish('chal-xyz');
177
+ let signedChallenge: string | undefined;
178
+ const results = await publishEvent(SAMPLE_EVENT, ['wss://auth'], 5000, (challenge, url) => {
179
+ signedChallenge = challenge;
180
+ return fakeAuthSigner(challenge, url);
181
+ });
182
+ expect(results[0]!.ok).toBe(true);
183
+ expect(signedChallenge).toBe('chal-xyz'); // the signer saw the relay's challenge
184
+ });
185
+
186
+ it('publishEvent WITHOUT an auth signer ignores the challenge and fails (no behavior change)', async () => {
187
+ mockNextAuthPublish('chal-xyz');
188
+ const results = await publishEvent(SAMPLE_EVENT, ['wss://auth'], 200);
189
+ expect(results[0]!.ok).toBe(false); // never authed → no OK → times out
190
+ }, 10_000);
191
+
192
+ it('queryEvents completes the AUTH handshake then serves the REQ', async () => {
193
+ const ev = { ...SAMPLE_EVENT, id: 'c'.repeat(64) };
194
+ mockNextAuthQuery('chal-q', [ev]);
195
+ const result = await queryEvents({ kinds: [30078] }, ['wss://auth'], 5000, fakeAuthSigner);
196
+ expect(result.events).toHaveLength(1);
197
+ expect(result.events[0]!.id).toBe('c'.repeat(64));
198
+ });
199
+ });
200
+
155
201
  // ─────────────────────────────────────────────────────────────────────────
156
202
  // Mock WebSocket harness
157
203
  // ─────────────────────────────────────────────────────────────────────────
@@ -161,7 +207,10 @@ type Outcome =
161
207
  | { kind: 'error' }
162
208
  | { kind: 'query'; events: NostrEvent[] }
163
209
  | { kind: 'query_error' }
164
- | { kind: 'query_hang' };
210
+ | { kind: 'query_hang' }
211
+ // NIP-42: challenge first, then accept the re-sent EVENT/REQ after AUTH.
212
+ | { kind: 'auth_then_ok'; challenge: string }
213
+ | { kind: 'auth_then_query'; challenge: string; events: NostrEvent[] };
165
214
 
166
215
  const outcomes: Outcome[] = [];
167
216
 
@@ -179,6 +228,30 @@ function mockNextQuery(detail: NostrEvent[] | 'error' | 'hang') {
179
228
  else outcomes.push({ kind: 'query' as const, events: detail });
180
229
  }
181
230
 
231
+ /** A relay that challenges with NIP-42 AUTH before accepting the publish. */
232
+ function mockNextAuthPublish(challenge: string) {
233
+ outcomes.push({ kind: 'auth_then_ok', challenge });
234
+ }
235
+
236
+ /** A relay that challenges with NIP-42 AUTH before serving the query. */
237
+ function mockNextAuthQuery(challenge: string, events: NostrEvent[]) {
238
+ outcomes.push({ kind: 'auth_then_query', challenge, events });
239
+ }
240
+
241
+ /** A throwaway kind-22242 AUTH signer for tests (no real crypto needed). */
242
+ const fakeAuthSigner = (challenge: string, relayUrl: string): NostrEvent => ({
243
+ id: 'a'.repeat(64),
244
+ kind: 22242,
245
+ pubkey: 'f'.repeat(64),
246
+ created_at: 1735689600,
247
+ content: '',
248
+ tags: [
249
+ ['relay', relayUrl],
250
+ ['challenge', challenge],
251
+ ],
252
+ sig: 'cafebabe'.repeat(16),
253
+ });
254
+
182
255
  let originalWebSocket: typeof globalThis.WebSocket | undefined;
183
256
 
184
257
  function installMockWebSocket() {
@@ -198,6 +271,7 @@ function installMockWebSocket() {
198
271
  onclose: (() => void) | null = null;
199
272
 
200
273
  private outcome: Outcome | undefined;
274
+ private authChallenged = false; // NIP-42: challenge fired once per connection
201
275
 
202
276
  constructor(public url: string) {
203
277
  this.outcome = outcomes.shift();
@@ -208,11 +282,23 @@ function installMockWebSocket() {
208
282
  send(raw: string) {
209
283
  const frame = JSON.parse(raw) as unknown[];
210
284
  const type = frame[0];
285
+ if (type === 'AUTH') {
286
+ // The client answered our NIP-42 challenge; nothing to do — the
287
+ // re-sent EVENT/REQ that follows is what we accept.
288
+ return;
289
+ }
211
290
  if (type === 'EVENT') {
212
291
  const event = frame[1] as NostrEvent;
213
292
  queueMicrotask(() => {
214
293
  if (!this.outcome) return;
215
- if (this.outcome.kind === 'publish_ok') {
294
+ if (this.outcome.kind === 'auth_then_ok') {
295
+ if (!this.authChallenged) {
296
+ this.authChallenged = true;
297
+ this.onmessage?.({ data: JSON.stringify(['AUTH', this.outcome.challenge]) });
298
+ } else {
299
+ this.onmessage?.({ data: JSON.stringify(['OK', event.id, true, '']) });
300
+ }
301
+ } else if (this.outcome.kind === 'publish_ok') {
216
302
  this.onmessage?.({
217
303
  data: JSON.stringify(['OK', event.id, this.outcome.ok, this.outcome.reason ?? '']),
218
304
  });
@@ -224,7 +310,17 @@ function installMockWebSocket() {
224
310
  const subId = frame[1] as string;
225
311
  queueMicrotask(() => {
226
312
  if (!this.outcome) return;
227
- if (this.outcome.kind === 'query') {
313
+ if (this.outcome.kind === 'auth_then_query') {
314
+ if (!this.authChallenged) {
315
+ this.authChallenged = true;
316
+ this.onmessage?.({ data: JSON.stringify(['AUTH', this.outcome.challenge]) });
317
+ return;
318
+ }
319
+ for (const ev of this.outcome.events) {
320
+ this.onmessage?.({ data: JSON.stringify(['EVENT', subId, ev]) });
321
+ }
322
+ this.onmessage?.({ data: JSON.stringify(['EOSE', subId]) });
323
+ } else if (this.outcome.kind === 'query') {
228
324
  for (const ev of this.outcome.events) {
229
325
  this.onmessage?.({ data: JSON.stringify(['EVENT', subId, ev]) });
230
326
  }
@@ -252,3 +348,42 @@ function restoreWebSocket() {
252
348
  if (originalWebSocket) globalThis.WebSocket = originalWebSocket;
253
349
  vi.unstubAllGlobals();
254
350
  }
351
+
352
+ // ─────────────────────────────────────────────────────────────────────────
353
+ // Filter key space — NIP-12 indexed tags are single-letter only.
354
+ //
355
+ // These assertions are compile-time. They exist because the same silently-
356
+ // empty query shipped in four codebases (OC Vote's tally, OC Agent's
357
+ // revocation lookup, @orangecheck/sdk's check(), and this package's own type)
358
+ // while `Filter` still advertised '#poll_id' / '#voter' / '#creator' as valid.
359
+ // A multi-letter filter is not a style question — a conforming relay cannot
360
+ // match it, so the query returns nothing and the caller believes the answer.
361
+ // ─────────────────────────────────────────────────────────────────────────
362
+ describe('Filter key space', () => {
363
+ it('accepts single-letter indexed tag filters', () => {
364
+ const ok: Filter = { kinds: [30081], '#t': ['abc'], '#d': ['x'], '#p': ['y'] };
365
+ expect(ok['#t']).toEqual(['abc']);
366
+ });
367
+
368
+ it('rejects multi-letter tag filters at compile time', () => {
369
+ const bad: Filter = {
370
+ kinds: [30081],
371
+ // @ts-expect-error '#poll_id' is not indexable — relays never match it.
372
+ '#poll_id': ['abc'],
373
+ };
374
+ // The value still exists at runtime; the point is that tsc refuses it.
375
+ expect((bad as Record<string, unknown>)['#poll_id']).toEqual(['abc']);
376
+ });
377
+
378
+ it('rejects the other names that actually shipped', () => {
379
+ const a: Filter = {
380
+ // @ts-expect-error not indexable
381
+ '#delegation': ['x'],
382
+ };
383
+ const b: Filter = {
384
+ // @ts-expect-error not indexable
385
+ '#address': ['x'],
386
+ };
387
+ expect([a, b]).toHaveLength(2);
388
+ });
389
+ });
package/src/index.ts CHANGED
@@ -41,17 +41,25 @@ type ValidRelaySet<T extends readonly string[]> =
41
41
  T;
42
42
 
43
43
  const _RELAYS: ValidRelaySet<readonly [
44
- 'wss://relay.nostr.band',
45
44
  'wss://nos.lol',
46
45
  'wss://relay.primal.net',
47
46
  'wss://offchain.pub',
47
+ 'wss://relay.snort.social',
48
48
  'wss://relay.ochk.io',
49
49
  ]> = [
50
- 'wss://relay.nostr.band',
51
50
  'wss://nos.lol',
52
51
  'wss://relay.primal.net',
53
52
  'wss://offchain.pub',
54
- // First-party family relay kind allowlist 30078–30086 + canonical
53
+ // relay.nostr.band sat here until 2026-09-03, when it stopped accepting
54
+ // TCP connections altogether (DNS resolves, port 443 never handshakes, and
55
+ // the apex nostr.band is down too — from two independent networks).
56
+ // relay.snort.social replaces it to keep the breadth this set exists for;
57
+ // it answered every probe. Note it ACCEPTS unindexed tag filters by
58
+ // ignoring them rather than rejecting, so it will happily return a flood of
59
+ // unrelated events for a multi-letter filter — which the Filter type in
60
+ // this file now makes impossible to express.
61
+ 'wss://relay.snort.social',
62
+ // First-party family relay — kind allowlist 30078-30087 + 30110-30114 + canonical
55
63
  // OC d-tag prefixes. Always co-published with public relays; never
56
64
  // the only copy. See https://github.com/orangecheck/oc-relay-infra.
57
65
  'wss://relay.ochk.io',
@@ -95,25 +103,43 @@ export interface PublishResult {
95
103
  attempts: number;
96
104
  }
97
105
 
98
- export interface Filter {
106
+ /**
107
+ * The tag names a relay is required to index.
108
+ *
109
+ * NIP-12 defines indexed tag filters over **single-letter** names only. A
110
+ * filter on a multi-letter name (`#poll_id`, `#delegation`, `#address`) is
111
+ * accepted by the wire format, forwarded, and then matches nothing on a
112
+ * conforming relay — it fails silently and returns an empty set.
113
+ *
114
+ * This type used to name `'#poll_id'`, `'#voter'` and `'#creator'` as
115
+ * legitimate filters ("Used by OC Vote"), with a `#${string}` catch-all that
116
+ * permitted any other. That is how the same bug reached four codebases:
117
+ * OC Vote's tally found zero ballots for every poll, OC Agent's revocation
118
+ * lookup found zero revocations and therefore honoured revoked delegations,
119
+ * and `@orangecheck/sdk`'s `check()` found zero attestations. Every one of
120
+ * them was a filter the type system had blessed.
121
+ *
122
+ * Restricting the key space to single letters turns that class of bug into a
123
+ * compile error. If you need to query by some value, the event has to carry
124
+ * it in a single-letter tag — most often `t` — and that is a fact about
125
+ * Nostr, not a preference.
126
+ */
127
+ type IndexableTagName =
128
+ | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm'
129
+ | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z'
130
+ | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M'
131
+ | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z';
132
+
133
+ /** `#a` … `#Z` — the only tag filters a relay must honour. */
134
+ export type IndexableTagFilter = `#${IndexableTagName}`;
135
+
136
+ export interface Filter extends Partial<Record<IndexableTagFilter, string[]>> {
99
137
  kinds?: number[];
100
138
  authors?: string[];
101
139
  ids?: string[];
102
140
  limit?: number;
103
141
  since?: number;
104
142
  until?: number;
105
- /** NIP-12 indexable `d`-tag filter. */
106
- '#d'?: string[];
107
- /** NIP-12 indexable single-letter tag filter. */
108
- '#t'?: string[];
109
- /** Used by OC Vote (kind 30081 ballots). */
110
- '#poll_id'?: string[];
111
- /** Used by OC Vote (kind 30081 ballots). */
112
- '#voter'?: string[];
113
- /** Used by OC Vote (kind 30080 polls). */
114
- '#creator'?: string[];
115
- /** Other indexable single-letter tags clients may filter on. */
116
- [key: `#${string}`]: string[] | undefined;
117
143
  }
118
144
 
119
145
  export interface QueryResult {
@@ -125,12 +151,24 @@ export interface QueryResult {
125
151
  // Internal — frame parsing + retry config.
126
152
  // ─────────────────────────────────────────────────────────────────────────
127
153
 
128
- type FrameType = 'OK' | 'EVENT' | 'EOSE' | 'NOTICE' | 'CLOSED';
154
+ type FrameType = 'OK' | 'EVENT' | 'EOSE' | 'NOTICE' | 'CLOSED' | 'AUTH';
129
155
  interface RelayFrame {
130
156
  type: FrameType;
131
157
  payload: unknown[];
132
158
  }
133
159
 
160
+ /**
161
+ * Sign a [NIP-42](https://nips.nostr.com/42) AUTH challenge. Given the relay's
162
+ * `challenge` string and `relayUrl`, return a signed kind-22242 event (tags
163
+ * `["relay", relayUrl]` + `["challenge", challenge]`). Passed in by the caller so
164
+ * this package stays dependency-free (no crypto here). Provide it only for
165
+ * `auth-required` relays; absent it, an AUTH challenge is ignored (today's behavior).
166
+ */
167
+ export type AuthSigner = (
168
+ challenge: string,
169
+ relayUrl: string
170
+ ) => NostrEvent | Promise<NostrEvent>;
171
+
134
172
  function parseFrame(raw: string): RelayFrame | null {
135
173
  try {
136
174
  const arr = JSON.parse(raw) as unknown[];
@@ -141,7 +179,8 @@ function parseFrame(raw: string): RelayFrame | null {
141
179
  type === 'EVENT' ||
142
180
  type === 'EOSE' ||
143
181
  type === 'NOTICE' ||
144
- type === 'CLOSED'
182
+ type === 'CLOSED' ||
183
+ type === 'AUTH'
145
184
  ) {
146
185
  return { type: type as FrameType, payload: arr.slice(1) };
147
186
  }
@@ -176,7 +215,8 @@ function delay(ms: number): Promise<void> {
176
215
  async function publishOne(
177
216
  url: string,
178
217
  event: NostrEvent,
179
- retry: RetryOptions
218
+ retry: RetryOptions,
219
+ auth?: AuthSigner
180
220
  ): Promise<PublishResult> {
181
221
  let attempts = 0;
182
222
  let backoff = retry.initialBackoffMs;
@@ -184,7 +224,7 @@ async function publishOne(
184
224
 
185
225
  while (attempts < retry.attempts) {
186
226
  attempts++;
187
- const attempt = await attemptPublish(url, event, retry.timeoutMs);
227
+ const attempt = await attemptPublish(url, event, retry.timeoutMs, auth);
188
228
  if (attempt.ok) {
189
229
  return {
190
230
  relay: url,
@@ -212,10 +252,12 @@ async function publishOne(
212
252
  function attemptPublish(
213
253
  url: string,
214
254
  event: NostrEvent,
215
- timeoutMs: number
255
+ timeoutMs: number,
256
+ auth?: AuthSigner
216
257
  ): Promise<{ ok: boolean; reason?: string; retryable: boolean }> {
217
258
  return new Promise((resolve) => {
218
259
  let settled = false;
260
+ let authed = false; // a NIP-42 AUTH handshake is attempted at most once
219
261
  let ws: WebSocket | null = null;
220
262
  const timer = setTimeout(() => {
221
263
  if (settled) return;
@@ -231,6 +273,22 @@ function attemptPublish(
231
273
  ws.onmessage = (msg) => {
232
274
  const frame = parseFrame(msg.data as string);
233
275
  if (!frame) return;
276
+ // NIP-42: an auth-required relay challenges before it accepts the
277
+ // EVENT. Sign the challenge with the caller's signer, send AUTH,
278
+ // then re-send the EVENT. Without an `auth` signer this is ignored.
279
+ if (frame.type === 'AUTH' && auth && !authed) {
280
+ authed = true;
281
+ const challenge = String(frame.payload[0] ?? '');
282
+ void Promise.resolve(auth(challenge, url))
283
+ .then((authEvent) => {
284
+ ws?.send(JSON.stringify(['AUTH', authEvent]));
285
+ ws?.send(JSON.stringify(['EVENT', event]));
286
+ })
287
+ .catch(() => {
288
+ /* signer failed → let the attempt time out / fail */
289
+ });
290
+ return;
291
+ }
234
292
  if (frame.type === 'OK' && frame.payload[0] === event.id) {
235
293
  if (settled) return;
236
294
  settled = true;
@@ -279,10 +337,11 @@ function attemptPublish(
279
337
  export async function publishEvent(
280
338
  event: NostrEvent,
281
339
  relays: readonly string[] = DEFAULT_RELAYS,
282
- timeoutMs = 5000
340
+ timeoutMs = 5000,
341
+ auth?: AuthSigner
283
342
  ): Promise<PublishResult[]> {
284
343
  const retry: RetryOptions = { ...DEFAULT_RETRY, timeoutMs };
285
- return Promise.all(relays.map((r) => publishOne(r, event, retry)));
344
+ return Promise.all(relays.map((r) => publishOne(r, event, retry, auth)));
286
345
  }
287
346
 
288
347
  // ─────────────────────────────────────────────────────────────────────────
@@ -301,7 +360,8 @@ export async function publishEvent(
301
360
  export async function queryEvents(
302
361
  filter: Filter,
303
362
  relays: readonly string[] = DEFAULT_RELAYS,
304
- timeoutMs = 1500
363
+ timeoutMs = 1500,
364
+ auth?: AuthSigner
305
365
  ): Promise<QueryResult> {
306
366
  const subId = 'ocnc-' + Math.random().toString(36).slice(2, 10);
307
367
  const byId = new Map<string, NostrEvent>();
@@ -312,6 +372,7 @@ export async function queryEvents(
312
372
  (url) =>
313
373
  new Promise<void>((resolve) => {
314
374
  let settled = false;
375
+ let authed = false; // NIP-42 handshake attempted at most once
315
376
  let count = 0;
316
377
  let reason: string | undefined;
317
378
  let ws: WebSocket | null = null;
@@ -335,6 +396,22 @@ export async function queryEvents(
335
396
  ws.onmessage = (msg) => {
336
397
  const frame = parseFrame(msg.data as string);
337
398
  if (!frame) return;
399
+ // NIP-42: auth-required relay challenges before serving the
400
+ // REQ — sign, send AUTH, then re-send the REQ. Ignored absent
401
+ // an `auth` signer (today's behavior).
402
+ if (frame.type === 'AUTH' && auth && !authed) {
403
+ authed = true;
404
+ const challenge = String(frame.payload[0] ?? '');
405
+ void Promise.resolve(auth(challenge, url))
406
+ .then((authEvent) => {
407
+ ws?.send(JSON.stringify(['AUTH', authEvent]));
408
+ ws?.send(JSON.stringify(['REQ', subId, filter]));
409
+ })
410
+ .catch(() => {
411
+ /* signer failed → let the query time out */
412
+ });
413
+ return;
414
+ }
338
415
  if (frame.type === 'EVENT' && frame.payload[0] === subId) {
339
416
  const event = frame.payload[1] as NostrEvent | undefined;
340
417
  if (event && event.id) {