@orangecheck/nostr-core 0.1.0 → 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/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @orangecheck/nostr-core
2
2
 
3
+ > **Full reference:** [docs.ochk.io/sdk/nostr-core](https://docs.ochk.io/sdk/nostr-core) — auto-generated from the TypeScript source on every release.
4
+ > Hand-written prose below is the high-level overview; the docs site is the source of truth for every export, type, and signature.
5
+
6
+
3
7
  Browser-compatible Nostr client used by every OrangeCheck family web app. Raw NIP-01 over WebSocket against a list of relays.
4
8
 
5
9
  ## Install
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
@@ -17,7 +17,7 @@ function parseFrame(raw) {
17
17
  const arr = JSON.parse(raw);
18
18
  if (!Array.isArray(arr) || arr.length === 0) return null;
19
19
  const type = arr[0];
20
- if (type === "OK" || type === "EVENT" || type === "EOSE" || type === "NOTICE" || type === "CLOSED") {
20
+ if (type === "OK" || type === "EVENT" || type === "EOSE" || type === "NOTICE" || type === "CLOSED" || type === "AUTH") {
21
21
  return { type, payload: arr.slice(1) };
22
22
  }
23
23
  return null;
@@ -34,13 +34,13 @@ var DEFAULT_RETRY = {
34
34
  function delay(ms) {
35
35
  return new Promise((resolve) => setTimeout(resolve, ms));
36
36
  }
37
- async function publishOne(url, event, retry) {
37
+ async function publishOne(url, event, retry, auth) {
38
38
  let attempts = 0;
39
39
  let backoff = retry.initialBackoffMs;
40
40
  let lastReason;
41
41
  while (attempts < retry.attempts) {
42
42
  attempts++;
43
- const attempt = await attemptPublish(url, event, retry.timeoutMs);
43
+ const attempt = await attemptPublish(url, event, retry.timeoutMs, auth);
44
44
  if (attempt.ok) {
45
45
  return {
46
46
  relay: url,
@@ -64,9 +64,10 @@ async function publishOne(url, event, retry) {
64
64
  ...lastReason ? { reason: lastReason } : {}
65
65
  };
66
66
  }
67
- function attemptPublish(url, event, timeoutMs) {
67
+ function attemptPublish(url, event, timeoutMs, auth) {
68
68
  return new Promise((resolve) => {
69
69
  let settled = false;
70
+ let authed = false;
70
71
  let ws = null;
71
72
  const timer = setTimeout(() => {
72
73
  if (settled) return;
@@ -83,6 +84,16 @@ function attemptPublish(url, event, timeoutMs) {
83
84
  ws.onmessage = (msg) => {
84
85
  const frame = parseFrame(msg.data);
85
86
  if (!frame) return;
87
+ if (frame.type === "AUTH" && auth && !authed) {
88
+ authed = true;
89
+ const challenge = String(frame.payload[0] ?? "");
90
+ void Promise.resolve(auth(challenge, url)).then((authEvent) => {
91
+ ws?.send(JSON.stringify(["AUTH", authEvent]));
92
+ ws?.send(JSON.stringify(["EVENT", event]));
93
+ }).catch(() => {
94
+ });
95
+ return;
96
+ }
86
97
  if (frame.type === "OK" && frame.payload[0] === event.id) {
87
98
  if (settled) return;
88
99
  settled = true;
@@ -124,11 +135,11 @@ function attemptPublish(url, event, timeoutMs) {
124
135
  }
125
136
  });
126
137
  }
127
- async function publishEvent(event, relays = DEFAULT_RELAYS, timeoutMs = 5e3) {
138
+ async function publishEvent(event, relays = DEFAULT_RELAYS, timeoutMs = 5e3, auth) {
128
139
  const retry = { ...DEFAULT_RETRY, timeoutMs };
129
- return Promise.all(relays.map((r) => publishOne(r, event, retry)));
140
+ return Promise.all(relays.map((r) => publishOne(r, event, retry, auth)));
130
141
  }
131
- async function queryEvents(filter, relays = DEFAULT_RELAYS, timeoutMs = 1500) {
142
+ async function queryEvents(filter, relays = DEFAULT_RELAYS, timeoutMs = 1500, auth) {
132
143
  const subId = "ocnc-" + Math.random().toString(36).slice(2, 10);
133
144
  const byId = /* @__PURE__ */ new Map();
134
145
  const status = [];
@@ -136,6 +147,7 @@ async function queryEvents(filter, relays = DEFAULT_RELAYS, timeoutMs = 1500) {
136
147
  relays.map(
137
148
  (url) => new Promise((resolve) => {
138
149
  let settled = false;
150
+ let authed = false;
139
151
  let count = 0;
140
152
  let reason;
141
153
  let ws = null;
@@ -160,6 +172,16 @@ async function queryEvents(filter, relays = DEFAULT_RELAYS, timeoutMs = 1500) {
160
172
  ws.onmessage = (msg) => {
161
173
  const frame = parseFrame(msg.data);
162
174
  if (!frame) return;
175
+ if (frame.type === "AUTH" && auth && !authed) {
176
+ authed = true;
177
+ const challenge = String(frame.payload[0] ?? "");
178
+ void Promise.resolve(auth(challenge, url)).then((authEvent) => {
179
+ ws?.send(JSON.stringify(["AUTH", authEvent]));
180
+ ws?.send(JSON.stringify(["REQ", subId, filter]));
181
+ }).catch(() => {
182
+ });
183
+ return;
184
+ }
163
185
  if (frame.type === "EVENT" && frame.payload[0] === subId) {
164
186
  const event = frame.payload[1];
165
187
  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 */\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://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\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
@@ -15,7 +15,7 @@ function parseFrame(raw) {
15
15
  const arr = JSON.parse(raw);
16
16
  if (!Array.isArray(arr) || arr.length === 0) return null;
17
17
  const type = arr[0];
18
- if (type === "OK" || type === "EVENT" || type === "EOSE" || type === "NOTICE" || type === "CLOSED") {
18
+ if (type === "OK" || type === "EVENT" || type === "EOSE" || type === "NOTICE" || type === "CLOSED" || type === "AUTH") {
19
19
  return { type, payload: arr.slice(1) };
20
20
  }
21
21
  return null;
@@ -32,13 +32,13 @@ var DEFAULT_RETRY = {
32
32
  function delay(ms) {
33
33
  return new Promise((resolve) => setTimeout(resolve, ms));
34
34
  }
35
- async function publishOne(url, event, retry) {
35
+ async function publishOne(url, event, retry, auth) {
36
36
  let attempts = 0;
37
37
  let backoff = retry.initialBackoffMs;
38
38
  let lastReason;
39
39
  while (attempts < retry.attempts) {
40
40
  attempts++;
41
- const attempt = await attemptPublish(url, event, retry.timeoutMs);
41
+ const attempt = await attemptPublish(url, event, retry.timeoutMs, auth);
42
42
  if (attempt.ok) {
43
43
  return {
44
44
  relay: url,
@@ -62,9 +62,10 @@ async function publishOne(url, event, retry) {
62
62
  ...lastReason ? { reason: lastReason } : {}
63
63
  };
64
64
  }
65
- function attemptPublish(url, event, timeoutMs) {
65
+ function attemptPublish(url, event, timeoutMs, auth) {
66
66
  return new Promise((resolve) => {
67
67
  let settled = false;
68
+ let authed = false;
68
69
  let ws = null;
69
70
  const timer = setTimeout(() => {
70
71
  if (settled) return;
@@ -81,6 +82,16 @@ function attemptPublish(url, event, timeoutMs) {
81
82
  ws.onmessage = (msg) => {
82
83
  const frame = parseFrame(msg.data);
83
84
  if (!frame) return;
85
+ if (frame.type === "AUTH" && auth && !authed) {
86
+ authed = true;
87
+ const challenge = String(frame.payload[0] ?? "");
88
+ void Promise.resolve(auth(challenge, url)).then((authEvent) => {
89
+ ws?.send(JSON.stringify(["AUTH", authEvent]));
90
+ ws?.send(JSON.stringify(["EVENT", event]));
91
+ }).catch(() => {
92
+ });
93
+ return;
94
+ }
84
95
  if (frame.type === "OK" && frame.payload[0] === event.id) {
85
96
  if (settled) return;
86
97
  settled = true;
@@ -122,11 +133,11 @@ function attemptPublish(url, event, timeoutMs) {
122
133
  }
123
134
  });
124
135
  }
125
- async function publishEvent(event, relays = DEFAULT_RELAYS, timeoutMs = 5e3) {
136
+ async function publishEvent(event, relays = DEFAULT_RELAYS, timeoutMs = 5e3, auth) {
126
137
  const retry = { ...DEFAULT_RETRY, timeoutMs };
127
- return Promise.all(relays.map((r) => publishOne(r, event, retry)));
138
+ return Promise.all(relays.map((r) => publishOne(r, event, retry, auth)));
128
139
  }
129
- async function queryEvents(filter, relays = DEFAULT_RELAYS, timeoutMs = 1500) {
140
+ async function queryEvents(filter, relays = DEFAULT_RELAYS, timeoutMs = 1500, auth) {
130
141
  const subId = "ocnc-" + Math.random().toString(36).slice(2, 10);
131
142
  const byId = /* @__PURE__ */ new Map();
132
143
  const status = [];
@@ -134,6 +145,7 @@ async function queryEvents(filter, relays = DEFAULT_RELAYS, timeoutMs = 1500) {
134
145
  relays.map(
135
146
  (url) => new Promise((resolve) => {
136
147
  let settled = false;
148
+ let authed = false;
137
149
  let count = 0;
138
150
  let reason;
139
151
  let ws = null;
@@ -158,6 +170,16 @@ async function queryEvents(filter, relays = DEFAULT_RELAYS, timeoutMs = 1500) {
158
170
  ws.onmessage = (msg) => {
159
171
  const frame = parseFrame(msg.data);
160
172
  if (!frame) return;
173
+ if (frame.type === "AUTH" && auth && !authed) {
174
+ authed = true;
175
+ const challenge = String(frame.payload[0] ?? "");
176
+ void Promise.resolve(auth(challenge, url)).then((authEvent) => {
177
+ ws?.send(JSON.stringify(["AUTH", authEvent]));
178
+ ws?.send(JSON.stringify(["REQ", subId, filter]));
179
+ }).catch(() => {
180
+ });
181
+ return;
182
+ }
161
183
  if (frame.type === "EVENT" && frame.payload[0] === subId) {
162
184
  const event = frame.payload[1];
163
185
  if (event && event.id) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";AA0CA,IAAM,OAAA,GAMD;AAAA,EACD,wBAAA;AAAA,EACA,eAAA;AAAA,EACA,wBAAA;AAAA,EACA,oBAAA;AAAA;AAAA;AAAA;AAAA,EAIA;AACJ,CAAA;AAQO,IAAM,iBAAoC,MAAA,CAAO,MAAA,CAAO,CAAC,GAAG,OAAO,CAAC;AA2D3E,SAAS,WAAW,GAAA,EAAgC;AAChD,EAAA,IAAI;AACA,IAAA,MAAM,GAAA,GAAM,IAAA,CAAK,KAAA,CAAM,GAAG,CAAA;AAC1B,IAAA,IAAI,CAAC,MAAM,OAAA,CAAQ,GAAG,KAAK,GAAA,CAAI,MAAA,KAAW,GAAG,OAAO,IAAA;AACpD,IAAA,MAAM,IAAA,GAAO,IAAI,CAAC,CAAA;AAClB,IAAA,IACI,IAAA,KAAS,QACT,IAAA,KAAS,OAAA,IACT,SAAS,MAAA,IACT,IAAA,KAAS,QAAA,IACT,IAAA,KAAS,QAAA,EACX;AACE,MAAA,OAAO,EAAE,IAAA,EAAyB,OAAA,EAAS,GAAA,CAAI,KAAA,CAAM,CAAC,CAAA,EAAE;AAAA,IAC5D;AACA,IAAA,OAAO,IAAA;AAAA,EACX,CAAA,CAAA,MAAQ;AACJ,IAAA,OAAO,IAAA;AAAA,EACX;AACJ;AASA,IAAM,aAAA,GAA8B;AAAA,EAChC,QAAA,EAAU,CAAA;AAAA,EACV,SAAA,EAAW,GAAA;AAAA,EACX,gBAAA,EAAkB,GAAA;AAAA,EAClB,YAAA,EAAc;AAClB,CAAA;AAEA,SAAS,MAAM,EAAA,EAA2B;AACtC,EAAA,OAAO,IAAI,OAAA,CAAQ,CAAC,YAAY,UAAA,CAAW,OAAA,EAAS,EAAE,CAAC,CAAA;AAC3D;AAMA,eAAe,UAAA,CACX,GAAA,EACA,KAAA,EACA,KAAA,EACsB;AACtB,EAAA,IAAI,QAAA,GAAW,CAAA;AACf,EAAA,IAAI,UAAU,KAAA,CAAM,gBAAA;AACpB,EAAA,IAAI,UAAA;AAEJ,EAAA,OAAO,QAAA,GAAW,MAAM,QAAA,EAAU;AAC9B,IAAA,QAAA,EAAA;AACA,IAAA,MAAM,UAAU,MAAM,cAAA,CAAe,GAAA,EAAK,KAAA,EAAO,MAAM,SAAS,CAAA;AAChE,IAAA,IAAI,QAAQ,EAAA,EAAI;AACZ,MAAA,OAAO;AAAA,QACH,KAAA,EAAO,GAAA;AAAA,QACP,EAAA,EAAI,IAAA;AAAA,QACJ,QAAA;AAAA,QACA,GAAI,QAAQ,MAAA,GAAS,EAAE,QAAQ,OAAA,CAAQ,MAAA,KAAW;AAAC,OACvD;AAAA,IACJ;AACA,IAAA,UAAA,GAAa,OAAA,CAAQ,MAAA;AACrB,IAAA,IAAI,OAAA,CAAQ,SAAA,IAAa,QAAA,GAAW,KAAA,CAAM,QAAA,EAAU;AAChD,MAAA,MAAM,MAAM,OAAO,CAAA;AACnB,MAAA,OAAA,GAAU,IAAA,CAAK,GAAA,CAAI,OAAA,GAAU,CAAA,EAAG,MAAM,YAAY,CAAA;AAClD,MAAA;AAAA,IACJ;AACA,IAAA;AAAA,EACJ;AACA,EAAA,OAAO;AAAA,IACH,KAAA,EAAO,GAAA;AAAA,IACP,EAAA,EAAI,KAAA;AAAA,IACJ,QAAA;AAAA,IACA,GAAI,UAAA,GAAa,EAAE,MAAA,EAAQ,UAAA,KAAe;AAAC,GAC/C;AACJ;AAEA,SAAS,cAAA,CACL,GAAA,EACA,KAAA,EACA,SAAA,EAC6D;AAC7D,EAAA,OAAO,IAAI,OAAA,CAAQ,CAAC,OAAA,KAAY;AAC5B,IAAA,IAAI,OAAA,GAAU,KAAA;AACd,IAAA,IAAI,EAAA,GAAuB,IAAA;AAC3B,IAAA,MAAM,KAAA,GAAQ,WAAW,MAAM;AAC3B,MAAA,IAAI,OAAA,EAAS;AACb,MAAA,OAAA,GAAU,IAAA;AACV,MAAA,IAAI;AACA,QAAA,EAAA,EAAI,KAAA,EAAM;AAAA,MACd,CAAA,CAAA,MAAQ;AAAA,MAAC;AACT,MAAA,OAAA,CAAQ,EAAE,EAAA,EAAI,KAAA,EAAO,QAAQ,SAAA,EAAW,SAAA,EAAW,MAAM,CAAA;AAAA,IAC7D,GAAG,SAAS,CAAA;AACZ,IAAA,IAAI;AACA,MAAA,EAAA,GAAK,IAAI,UAAU,GAAG,CAAA;AACtB,MAAA,EAAA,CAAG,MAAA,GAAS,MAAM,EAAA,EAAI,IAAA,CAAK,IAAA,CAAK,UAAU,CAAC,OAAA,EAAS,KAAK,CAAC,CAAC,CAAA;AAC3D,MAAA,EAAA,CAAG,SAAA,GAAY,CAAC,GAAA,KAAQ;AACpB,QAAA,MAAM,KAAA,GAAQ,UAAA,CAAW,GAAA,CAAI,IAAc,CAAA;AAC3C,QAAA,IAAI,CAAC,KAAA,EAAO;AACZ,QAAA,IAAI,KAAA,CAAM,SAAS,IAAA,IAAQ,KAAA,CAAM,QAAQ,CAAC,CAAA,KAAM,MAAM,EAAA,EAAI;AACtD,UAAA,IAAI,OAAA,EAAS;AACb,UAAA,OAAA,GAAU,IAAA;AACV,UAAA,YAAA,CAAa,KAAK,CAAA;AAClB,UAAA,IAAI;AACA,YAAA,EAAA,EAAI,KAAA,EAAM;AAAA,UACd,CAAA,CAAA,MAAQ;AAAA,UAAC;AACT,UAAA,MAAM,EAAA,GAAK,KAAA,CAAM,OAAA,CAAQ,CAAC,CAAA,KAAM,IAAA;AAChC,UAAA,MAAM,MAAA,GAAS,KAAA,CAAM,OAAA,CAAQ,CAAC,CAAA;AAC9B,UAAA,OAAA,CAAQ;AAAA,YACJ,EAAA;AAAA,YACA,GAAI,MAAA,GAAS,EAAE,MAAA,KAAW,EAAC;AAAA,YAC3B,SAAA,EAAW;AAAA,WACd,CAAA;AAAA,QACL;AAAA,MACJ,CAAA;AACA,MAAA,EAAA,CAAG,UAAU,MAAM;AACf,QAAA,IAAI,OAAA,EAAS;AACb,QAAA,OAAA,GAAU,IAAA;AACV,QAAA,YAAA,CAAa,KAAK,CAAA;AAClB,QAAA,OAAA,CAAQ,EAAE,EAAA,EAAI,KAAA,EAAO,QAAQ,iBAAA,EAAmB,SAAA,EAAW,MAAM,CAAA;AAAA,MACrE,CAAA;AACA,MAAA,EAAA,CAAG,UAAU,MAAM;AACf,QAAA,IAAI,OAAA,EAAS;AACb,QAAA,OAAA,GAAU,IAAA;AACV,QAAA,YAAA,CAAa,KAAK,CAAA;AAClB,QAAA,OAAA,CAAQ,EAAE,EAAA,EAAI,KAAA,EAAO,QAAQ,cAAA,EAAgB,SAAA,EAAW,MAAM,CAAA;AAAA,MAClE,CAAA;AAAA,IACJ,SAAS,GAAA,EAAK;AACV,MAAA,IAAI,OAAA,EAAS;AACb,MAAA,OAAA,GAAU,IAAA;AACV,MAAA,YAAA,CAAa,KAAK,CAAA;AAClB,MAAA,OAAA,CAAQ;AAAA,QACJ,EAAA,EAAI,KAAA;AAAA,QACJ,MAAA,EAAQ,GAAA,YAAe,KAAA,GAAQ,GAAA,CAAI,OAAA,GAAU,SAAA;AAAA,QAC7C,SAAA,EAAW;AAAA,OACd,CAAA;AAAA,IACL;AAAA,EACJ,CAAC,CAAA;AACL;AAMA,eAAsB,YAAA,CAClB,KAAA,EACA,MAAA,GAA4B,cAAA,EAC5B,YAAY,GAAA,EACY;AACxB,EAAA,MAAM,KAAA,GAAsB,EAAE,GAAG,aAAA,EAAe,SAAA,EAAU;AAC1D,EAAA,OAAO,OAAA,CAAQ,GAAA,CAAI,MAAA,CAAO,GAAA,CAAI,CAAC,CAAA,KAAM,UAAA,CAAW,CAAA,EAAG,KAAA,EAAO,KAAK,CAAC,CAAC,CAAA;AACrE;AAeA,eAAsB,WAAA,CAClB,MAAA,EACA,MAAA,GAA4B,cAAA,EAC5B,YAAY,IAAA,EACQ;AACpB,EAAA,MAAM,KAAA,GAAQ,OAAA,GAAU,IAAA,CAAK,MAAA,EAAO,CAAE,SAAS,EAAE,CAAA,CAAE,KAAA,CAAM,CAAA,EAAG,EAAE,CAAA;AAC9D,EAAA,MAAM,IAAA,uBAAW,GAAA,EAAwB;AACzC,EAAA,MAAM,SAAqC,EAAC;AAE5C,EAAA,MAAM,OAAA,CAAQ,GAAA;AAAA,IACV,MAAA,CAAO,GAAA;AAAA,MACH,CAAC,GAAA,KACG,IAAI,OAAA,CAAc,CAAC,OAAA,KAAY;AAC3B,QAAA,IAAI,OAAA,GAAU,KAAA;AACd,QAAA,IAAI,KAAA,GAAQ,CAAA;AACZ,QAAA,IAAI,MAAA;AACJ,QAAA,IAAI,EAAA,GAAuB,IAAA;AAC3B,QAAA,MAAM,KAAA,GAAQ,WAAW,MAAM;AAC3B,UAAA,IAAI,OAAA,EAAS;AACb,UAAA,OAAA,GAAU,IAAA;AACV,UAAA,IAAI;AACA,YAAA,EAAA,EAAI,KAAA,EAAM;AAAA,UACd,CAAA,CAAA,MAAQ;AAAA,UAAC;AACT,UAAA,MAAA,CAAO,IAAA,CAAK;AAAA,YACR,KAAA,EAAO,GAAA;AAAA,YACP,IAAI,KAAA,GAAQ,CAAA;AAAA,YACZ,QAAQ,MAAA,IAAU,SAAA;AAAA,YAClB,MAAA,EAAQ;AAAA,WACX,CAAA;AACD,UAAA,OAAA,EAAQ;AAAA,QACZ,GAAG,SAAS,CAAA;AACZ,QAAA,IAAI;AACA,UAAA,EAAA,GAAK,IAAI,UAAU,GAAG,CAAA;AACtB,UAAA,EAAA,CAAG,MAAA,GAAS,MAAM,EAAA,EAAI,IAAA,CAAK,IAAA,CAAK,SAAA,CAAU,CAAC,KAAA,EAAO,KAAA,EAAO,MAAM,CAAC,CAAC,CAAA;AACjE,UAAA,EAAA,CAAG,SAAA,GAAY,CAAC,GAAA,KAAQ;AACpB,YAAA,MAAM,KAAA,GAAQ,UAAA,CAAW,GAAA,CAAI,IAAc,CAAA;AAC3C,YAAA,IAAI,CAAC,KAAA,EAAO;AACZ,YAAA,IAAI,MAAM,IAAA,KAAS,OAAA,IAAW,MAAM,OAAA,CAAQ,CAAC,MAAM,KAAA,EAAO;AACtD,cAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,OAAA,CAAQ,CAAC,CAAA;AAC7B,cAAA,IAAI,KAAA,IAAS,MAAM,EAAA,EAAI;AACnB,gBAAA,IAAA,CAAK,GAAA,CAAI,KAAA,CAAM,EAAA,EAAI,KAAK,CAAA;AACxB,gBAAA,KAAA,EAAA;AAAA,cACJ;AAAA,YACJ,CAAA,MAAA,IAAW,MAAM,IAAA,KAAS,MAAA,IAAU,MAAM,OAAA,CAAQ,CAAC,MAAM,KAAA,EAAO;AAC5D,cAAA,IAAI,OAAA,EAAS;AACb,cAAA,OAAA,GAAU,IAAA;AACV,cAAA,YAAA,CAAa,KAAK,CAAA;AAClB,cAAA,IAAI;AACA,gBAAA,EAAA,EAAI,KAAK,IAAA,CAAK,SAAA,CAAU,CAAC,OAAA,EAAS,KAAK,CAAC,CAAC,CAAA;AACzC,gBAAA,EAAA,EAAI,KAAA,EAAM;AAAA,cACd,CAAA,CAAA,MAAQ;AAAA,cAAC;AACT,cAAA,MAAA,CAAO,IAAA,CAAK,EAAE,KAAA,EAAO,GAAA,EAAK,IAAI,IAAA,EAAM,MAAA,EAAQ,OAAO,CAAA;AACnD,cAAA,OAAA,EAAQ;AAAA,YACZ,CAAA,MAAA,IAAW,KAAA,CAAM,IAAA,KAAS,QAAA,EAAU;AAChC,cAAA,MAAA,GAAS,MAAA,CAAO,KAAA,CAAM,OAAA,CAAQ,CAAC,KAAK,QAAQ,CAAA;AAAA,YAChD;AAAA,UACJ,CAAA;AACA,UAAA,EAAA,CAAG,UAAU,MAAM;AACf,YAAA,IAAI,OAAA,EAAS;AACb,YAAA,OAAA,GAAU,IAAA;AACV,YAAA,YAAA,CAAa,KAAK,CAAA;AAClB,YAAA,MAAA,CAAO,IAAA,CAAK;AAAA,cACR,KAAA,EAAO,GAAA;AAAA,cACP,EAAA,EAAI,KAAA;AAAA,cACJ,MAAA,EAAQ,UAAA;AAAA,cACR,MAAA,EAAQ;AAAA,aACX,CAAA;AACD,YAAA,OAAA,EAAQ;AAAA,UACZ,CAAA;AACA,UAAA,EAAA,CAAG,UAAU,MAAM;AACf,YAAA,IAAI,OAAA,EAAS;AACb,YAAA,OAAA,GAAU,IAAA;AACV,YAAA,YAAA,CAAa,KAAK,CAAA;AAClB,YAAA,MAAA,CAAO,IAAA,CAAK;AAAA,cACR,KAAA,EAAO,GAAA;AAAA,cACP,IAAI,KAAA,GAAQ,CAAA;AAAA,cACZ,QAAQ,MAAA,IAAU,cAAA;AAAA,cAClB,MAAA,EAAQ;AAAA,aACX,CAAA;AACD,YAAA,OAAA,EAAQ;AAAA,UACZ,CAAA;AAAA,QACJ,SAAS,GAAA,EAAK;AACV,UAAA,IAAI,OAAA,EAAS;AACb,UAAA,OAAA,GAAU,IAAA;AACV,UAAA,YAAA,CAAa,KAAK,CAAA;AAClB,UAAA,MAAA,CAAO,IAAA,CAAK;AAAA,YACR,KAAA,EAAO,GAAA;AAAA,YACP,EAAA,EAAI,KAAA;AAAA,YACJ,MAAA,EAAQ,GAAA,YAAe,KAAA,GAAQ,GAAA,CAAI,OAAA,GAAU,SAAA;AAAA,YAC7C,MAAA,EAAQ;AAAA,WACX,CAAA;AACD,UAAA,OAAA,EAAQ;AAAA,QACZ;AAAA,MACJ,CAAC;AAAA;AACT,GACJ;AAEA,EAAA,OAAO;AAAA,IACH,MAAA,EAAQ,KAAA,CAAM,IAAA,CAAK,IAAA,CAAK,QAAQ,CAAA,CAAE,IAAA,CAAK,CAAC,CAAA,EAAG,CAAA,KAAM,CAAA,CAAE,UAAA,GAAa,EAAE,UAAU,CAAA;AAAA,IAC5E,WAAA,EAAa;AAAA,GACjB;AACJ","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 */\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://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\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.0",
3
+ "version": "0.2.0",
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",
@@ -0,0 +1,376 @@
1
+ /**
2
+ * @orangecheck/nostr-core — unit tests
3
+ *
4
+ * Covers the bits that don't need a live relay:
5
+ * - DEFAULT_RELAYS shape + invariants
6
+ * - frame parsing (the inner protocol parser)
7
+ * - publishEvent / queryEvents end-to-end against a mock WebSocket
8
+ *
9
+ * The mock WebSocket implementation lives at the bottom of this file.
10
+ * It implements just enough of the WHATWG WebSocket interface to drive
11
+ * publishOne / attemptPublish + queryEvents through their happy paths
12
+ * and a few error paths.
13
+ */
14
+
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
+ import {
17
+ DEFAULT_RELAYS,
18
+ publishEvent,
19
+ queryEvents,
20
+ type Filter,
21
+ type NostrEvent,
22
+ } from './index';
23
+
24
+ // ── DEFAULT_RELAYS ───────────────────────────────────────────────────────
25
+
26
+ describe('DEFAULT_RELAYS', () => {
27
+ it('has at least 5 relays', () => {
28
+ expect(DEFAULT_RELAYS.length).toBeGreaterThanOrEqual(5);
29
+ });
30
+
31
+ it('includes the family first-party relay', () => {
32
+ expect(DEFAULT_RELAYS).toContain('wss://relay.ochk.io');
33
+ });
34
+
35
+ it('is not relay.ochk.io alone (BYPASS invariant)', () => {
36
+ expect(DEFAULT_RELAYS).not.toEqual(['wss://relay.ochk.io']);
37
+ });
38
+
39
+ it('is frozen at runtime — consumers cannot mutate', () => {
40
+ expect(() => {
41
+ (DEFAULT_RELAYS as unknown as string[]).push('wss://attack.example');
42
+ }).toThrow();
43
+ });
44
+
45
+ it('every entry is a wss:// or ws:// URL', () => {
46
+ for (const relay of DEFAULT_RELAYS) {
47
+ expect(relay).toMatch(/^wss?:\/\//);
48
+ }
49
+ });
50
+ });
51
+
52
+ // ── publishEvent + queryEvents — mock-WebSocket-backed integration ──────
53
+
54
+ const SAMPLE_EVENT: NostrEvent = {
55
+ id: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
56
+ kind: 30078,
57
+ pubkey: 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899',
58
+ created_at: 1735689600,
59
+ content: 'test',
60
+ tags: [['d', 'oc-test:abc']],
61
+ sig: 'cafebabe'.repeat(16),
62
+ };
63
+
64
+ beforeEach(() => {
65
+ installMockWebSocket();
66
+ });
67
+
68
+ afterEach(() => {
69
+ restoreWebSocket();
70
+ });
71
+
72
+ describe('publishEvent', () => {
73
+ it('returns one PublishResult per relay', async () => {
74
+ mockNextHandshake('OK', { ok: true });
75
+ mockNextHandshake('OK', { ok: true });
76
+ const results = await publishEvent(SAMPLE_EVENT, ['wss://a', 'wss://b']);
77
+ expect(results).toHaveLength(2);
78
+ expect(results[0]!.relay).toBe('wss://a');
79
+ expect(results[1]!.relay).toBe('wss://b');
80
+ });
81
+
82
+ it('marks ok: true when relay sends OK true', async () => {
83
+ mockNextHandshake('OK', { ok: true });
84
+ const results = await publishEvent(SAMPLE_EVENT, ['wss://a']);
85
+ expect(results[0]!.ok).toBe(true);
86
+ expect(results[0]!.attempts).toBe(1);
87
+ });
88
+
89
+ it('marks ok: false when relay rejects with OK false + reason', async () => {
90
+ mockNextHandshake('OK', { ok: false, reason: 'blocked: bad shape' });
91
+ const results = await publishEvent(SAMPLE_EVENT, ['wss://a']);
92
+ expect(results[0]!.ok).toBe(false);
93
+ expect(results[0]!.reason).toBe('blocked: bad shape');
94
+ });
95
+
96
+ it('retries on transient failure (3 attempts, exits ok: false)', async () => {
97
+ // 3 errors → exhausts retries. We assert the shape (attempts=3,
98
+ // ok=false) — the precise lastReason can race with the timeout
99
+ // on slow runners and isn't load-bearing for the contract.
100
+ mockNextHandshake('error');
101
+ mockNextHandshake('error');
102
+ mockNextHandshake('error');
103
+ const results = await publishEvent(SAMPLE_EVENT, ['wss://flaky'], 1000);
104
+ expect(results[0]!.ok).toBe(false);
105
+ expect(results[0]!.attempts).toBe(3);
106
+ }, 30_000);
107
+
108
+ it('publishes to all relays in parallel', async () => {
109
+ mockNextHandshake('OK', { ok: true });
110
+ mockNextHandshake('OK', { ok: true });
111
+ mockNextHandshake('OK', { ok: true });
112
+ const start = Date.now();
113
+ await publishEvent(SAMPLE_EVENT, ['wss://a', 'wss://b', 'wss://c']);
114
+ const elapsed = Date.now() - start;
115
+ // If they ran serially with the mock's tiny delay we'd see ≥ 30ms.
116
+ // Parallel should resolve in well under that.
117
+ expect(elapsed).toBeLessThan(100);
118
+ });
119
+ });
120
+
121
+ describe('queryEvents', () => {
122
+ it('returns events streamed via EVENT, dedupes across relays', async () => {
123
+ const ev1 = { ...SAMPLE_EVENT, id: 'a'.repeat(64) };
124
+ const ev2 = { ...SAMPLE_EVENT, id: 'b'.repeat(64), created_at: 1735689700 };
125
+ mockNextQuery([ev1, ev2]);
126
+ // Same event id on a second relay — should dedupe.
127
+ mockNextQuery([ev1]);
128
+
129
+ const result = await queryEvents({ kinds: [30078] }, ['wss://a', 'wss://b']);
130
+ expect(result.events).toHaveLength(2);
131
+ // Sorted by created_at desc.
132
+ expect(result.events[0]!.id).toBe('b'.repeat(64));
133
+ expect(result.events[1]!.id).toBe('a'.repeat(64));
134
+ expect(result.relayStatus.filter((s) => s.ok)).toHaveLength(2);
135
+ });
136
+
137
+ it('reports per-relay status — failure on one does not block others', async () => {
138
+ const ev = { ...SAMPLE_EVENT };
139
+ mockNextQuery([ev]);
140
+ mockNextQuery('error');
141
+
142
+ const result = await queryEvents({ kinds: [30078] }, ['wss://good', 'wss://bad']);
143
+ expect(result.events).toHaveLength(1);
144
+ const okStatuses = result.relayStatus.filter((s) => s.ok);
145
+ const failStatuses = result.relayStatus.filter((s) => !s.ok);
146
+ expect(okStatuses).toHaveLength(1);
147
+ expect(failStatuses).toHaveLength(1);
148
+ });
149
+
150
+ it('respects timeoutMs when relay never sends EOSE', async () => {
151
+ mockNextQuery('hang');
152
+ const start = Date.now();
153
+ const result = await queryEvents({ kinds: [30078] }, ['wss://hang'], 200);
154
+ const elapsed = Date.now() - start;
155
+ expect(elapsed).toBeGreaterThanOrEqual(200);
156
+ expect(elapsed).toBeLessThan(500);
157
+ expect(result.relayStatus[0]!.reason).toBe('timeout');
158
+ });
159
+ });
160
+
161
+ describe('NIP-42 AUTH (additive)', () => {
162
+ it('publishEvent completes the AUTH handshake then publishes', async () => {
163
+ mockNextAuthPublish('chal-xyz');
164
+ let signedChallenge: string | undefined;
165
+ const results = await publishEvent(SAMPLE_EVENT, ['wss://auth'], 5000, (challenge, url) => {
166
+ signedChallenge = challenge;
167
+ return fakeAuthSigner(challenge, url);
168
+ });
169
+ expect(results[0]!.ok).toBe(true);
170
+ expect(signedChallenge).toBe('chal-xyz'); // the signer saw the relay's challenge
171
+ });
172
+
173
+ it('publishEvent WITHOUT an auth signer ignores the challenge and fails (no behavior change)', async () => {
174
+ mockNextAuthPublish('chal-xyz');
175
+ const results = await publishEvent(SAMPLE_EVENT, ['wss://auth'], 200);
176
+ expect(results[0]!.ok).toBe(false); // never authed → no OK → times out
177
+ }, 10_000);
178
+
179
+ it('queryEvents completes the AUTH handshake then serves the REQ', async () => {
180
+ const ev = { ...SAMPLE_EVENT, id: 'c'.repeat(64) };
181
+ mockNextAuthQuery('chal-q', [ev]);
182
+ const result = await queryEvents({ kinds: [30078] }, ['wss://auth'], 5000, fakeAuthSigner);
183
+ expect(result.events).toHaveLength(1);
184
+ expect(result.events[0]!.id).toBe('c'.repeat(64));
185
+ });
186
+ });
187
+
188
+ // ─────────────────────────────────────────────────────────────────────────
189
+ // Mock WebSocket harness
190
+ // ─────────────────────────────────────────────────────────────────────────
191
+
192
+ type Outcome =
193
+ | { kind: 'publish_ok'; ok: boolean; reason?: string }
194
+ | { kind: 'error' }
195
+ | { kind: 'query'; events: NostrEvent[] }
196
+ | { kind: 'query_error' }
197
+ | { kind: 'query_hang' }
198
+ // NIP-42: challenge first, then accept the re-sent EVENT/REQ after AUTH.
199
+ | { kind: 'auth_then_ok'; challenge: string }
200
+ | { kind: 'auth_then_query'; challenge: string; events: NostrEvent[] };
201
+
202
+ const outcomes: Outcome[] = [];
203
+
204
+ function mockNextHandshake(type: 'OK' | 'error', detail?: { ok: boolean; reason?: string }) {
205
+ if (type === 'OK') {
206
+ outcomes.push({ kind: 'publish_ok', ok: detail!.ok, reason: detail?.reason });
207
+ } else {
208
+ outcomes.push({ kind: 'error' });
209
+ }
210
+ }
211
+
212
+ function mockNextQuery(detail: NostrEvent[] | 'error' | 'hang') {
213
+ if (detail === 'error') outcomes.push({ kind: 'query_error' });
214
+ else if (detail === 'hang') outcomes.push({ kind: 'query_hang' });
215
+ else outcomes.push({ kind: 'query' as const, events: detail });
216
+ }
217
+
218
+ /** A relay that challenges with NIP-42 AUTH before accepting the publish. */
219
+ function mockNextAuthPublish(challenge: string) {
220
+ outcomes.push({ kind: 'auth_then_ok', challenge });
221
+ }
222
+
223
+ /** A relay that challenges with NIP-42 AUTH before serving the query. */
224
+ function mockNextAuthQuery(challenge: string, events: NostrEvent[]) {
225
+ outcomes.push({ kind: 'auth_then_query', challenge, events });
226
+ }
227
+
228
+ /** A throwaway kind-22242 AUTH signer for tests (no real crypto needed). */
229
+ const fakeAuthSigner = (challenge: string, relayUrl: string): NostrEvent => ({
230
+ id: 'a'.repeat(64),
231
+ kind: 22242,
232
+ pubkey: 'f'.repeat(64),
233
+ created_at: 1735689600,
234
+ content: '',
235
+ tags: [
236
+ ['relay', relayUrl],
237
+ ['challenge', challenge],
238
+ ],
239
+ sig: 'cafebabe'.repeat(16),
240
+ });
241
+
242
+ let originalWebSocket: typeof globalThis.WebSocket | undefined;
243
+
244
+ function installMockWebSocket() {
245
+ originalWebSocket = globalThis.WebSocket;
246
+ outcomes.length = 0;
247
+
248
+ class MockWebSocket {
249
+ static CONNECTING = 0;
250
+ static OPEN = 1;
251
+ static CLOSING = 2;
252
+ static CLOSED = 3;
253
+ readyState = 1;
254
+
255
+ onopen: (() => void) | null = null;
256
+ onmessage: ((ev: { data: string }) => void) | null = null;
257
+ onerror: ((ev: unknown) => void) | null = null;
258
+ onclose: (() => void) | null = null;
259
+
260
+ private outcome: Outcome | undefined;
261
+ private authChallenged = false; // NIP-42: challenge fired once per connection
262
+
263
+ constructor(public url: string) {
264
+ this.outcome = outcomes.shift();
265
+ // Fire onopen + react asynchronously so the caller can attach handlers.
266
+ queueMicrotask(() => this.onopen?.());
267
+ }
268
+
269
+ send(raw: string) {
270
+ const frame = JSON.parse(raw) as unknown[];
271
+ const type = frame[0];
272
+ if (type === 'AUTH') {
273
+ // The client answered our NIP-42 challenge; nothing to do — the
274
+ // re-sent EVENT/REQ that follows is what we accept.
275
+ return;
276
+ }
277
+ if (type === 'EVENT') {
278
+ const event = frame[1] as NostrEvent;
279
+ queueMicrotask(() => {
280
+ if (!this.outcome) return;
281
+ if (this.outcome.kind === 'auth_then_ok') {
282
+ if (!this.authChallenged) {
283
+ this.authChallenged = true;
284
+ this.onmessage?.({ data: JSON.stringify(['AUTH', this.outcome.challenge]) });
285
+ } else {
286
+ this.onmessage?.({ data: JSON.stringify(['OK', event.id, true, '']) });
287
+ }
288
+ } else if (this.outcome.kind === 'publish_ok') {
289
+ this.onmessage?.({
290
+ data: JSON.stringify(['OK', event.id, this.outcome.ok, this.outcome.reason ?? '']),
291
+ });
292
+ } else if (this.outcome.kind === 'error') {
293
+ this.onerror?.({});
294
+ }
295
+ });
296
+ } else if (type === 'REQ') {
297
+ const subId = frame[1] as string;
298
+ queueMicrotask(() => {
299
+ if (!this.outcome) return;
300
+ if (this.outcome.kind === 'auth_then_query') {
301
+ if (!this.authChallenged) {
302
+ this.authChallenged = true;
303
+ this.onmessage?.({ data: JSON.stringify(['AUTH', this.outcome.challenge]) });
304
+ return;
305
+ }
306
+ for (const ev of this.outcome.events) {
307
+ this.onmessage?.({ data: JSON.stringify(['EVENT', subId, ev]) });
308
+ }
309
+ this.onmessage?.({ data: JSON.stringify(['EOSE', subId]) });
310
+ } else if (this.outcome.kind === 'query') {
311
+ for (const ev of this.outcome.events) {
312
+ this.onmessage?.({ data: JSON.stringify(['EVENT', subId, ev]) });
313
+ }
314
+ this.onmessage?.({ data: JSON.stringify(['EOSE', subId]) });
315
+ } else if (this.outcome.kind === 'query_error') {
316
+ this.onerror?.({});
317
+ } else if (this.outcome.kind === 'query_hang') {
318
+ // do nothing — caller's timeout fires
319
+ }
320
+ });
321
+ } else if (type === 'CLOSE') {
322
+ // queryEvents sends CLOSE after EOSE; we don't need to react.
323
+ }
324
+ }
325
+
326
+ close() {
327
+ this.readyState = 3;
328
+ }
329
+ }
330
+
331
+ vi.stubGlobal('WebSocket', MockWebSocket);
332
+ }
333
+
334
+ function restoreWebSocket() {
335
+ if (originalWebSocket) globalThis.WebSocket = originalWebSocket;
336
+ vi.unstubAllGlobals();
337
+ }
338
+
339
+ // ─────────────────────────────────────────────────────────────────────────
340
+ // Filter key space — NIP-12 indexed tags are single-letter only.
341
+ //
342
+ // These assertions are compile-time. They exist because the same silently-
343
+ // empty query shipped in four codebases (OC Vote's tally, OC Agent's
344
+ // revocation lookup, @orangecheck/sdk's check(), and this package's own type)
345
+ // while `Filter` still advertised '#poll_id' / '#voter' / '#creator' as valid.
346
+ // A multi-letter filter is not a style question — a conforming relay cannot
347
+ // match it, so the query returns nothing and the caller believes the answer.
348
+ // ─────────────────────────────────────────────────────────────────────────
349
+ describe('Filter key space', () => {
350
+ it('accepts single-letter indexed tag filters', () => {
351
+ const ok: Filter = { kinds: [30081], '#t': ['abc'], '#d': ['x'], '#p': ['y'] };
352
+ expect(ok['#t']).toEqual(['abc']);
353
+ });
354
+
355
+ it('rejects multi-letter tag filters at compile time', () => {
356
+ const bad: Filter = {
357
+ kinds: [30081],
358
+ // @ts-expect-error '#poll_id' is not indexable — relays never match it.
359
+ '#poll_id': ['abc'],
360
+ };
361
+ // The value still exists at runtime; the point is that tsc refuses it.
362
+ expect((bad as Record<string, unknown>)['#poll_id']).toEqual(['abc']);
363
+ });
364
+
365
+ it('rejects the other names that actually shipped', () => {
366
+ const a: Filter = {
367
+ // @ts-expect-error not indexable
368
+ '#delegation': ['x'],
369
+ };
370
+ const b: Filter = {
371
+ // @ts-expect-error not indexable
372
+ '#address': ['x'],
373
+ };
374
+ expect([a, b]).toHaveLength(2);
375
+ });
376
+ });
package/src/index.ts CHANGED
@@ -62,6 +62,15 @@ const _RELAYS: ValidRelaySet<readonly [
62
62
  *
63
63
  * Frozen at runtime; consumers MAY pass an explicit `relays` arg to any
64
64
  * function in this package to override.
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * import { DEFAULT_RELAYS, publishEvent } from '@orangecheck/nostr-core';
69
+ *
70
+ * const results = await publishEvent(myEvent);
71
+ * // → publishes to all 5 relays in DEFAULT_RELAYS in parallel
72
+ * console.log(`accepted on ${results.filter(r => r.ok).length}/${results.length}`);
73
+ * ```
65
74
  */
66
75
  export const DEFAULT_RELAYS: readonly string[] = Object.freeze([..._RELAYS]);
67
76
 
@@ -86,25 +95,43 @@ export interface PublishResult {
86
95
  attempts: number;
87
96
  }
88
97
 
89
- export interface Filter {
98
+ /**
99
+ * The tag names a relay is required to index.
100
+ *
101
+ * NIP-12 defines indexed tag filters over **single-letter** names only. A
102
+ * filter on a multi-letter name (`#poll_id`, `#delegation`, `#address`) is
103
+ * accepted by the wire format, forwarded, and then matches nothing on a
104
+ * conforming relay — it fails silently and returns an empty set.
105
+ *
106
+ * This type used to name `'#poll_id'`, `'#voter'` and `'#creator'` as
107
+ * legitimate filters ("Used by OC Vote"), with a `#${string}` catch-all that
108
+ * permitted any other. That is how the same bug reached four codebases:
109
+ * OC Vote's tally found zero ballots for every poll, OC Agent's revocation
110
+ * lookup found zero revocations and therefore honoured revoked delegations,
111
+ * and `@orangecheck/sdk`'s `check()` found zero attestations. Every one of
112
+ * them was a filter the type system had blessed.
113
+ *
114
+ * Restricting the key space to single letters turns that class of bug into a
115
+ * compile error. If you need to query by some value, the event has to carry
116
+ * it in a single-letter tag — most often `t` — and that is a fact about
117
+ * Nostr, not a preference.
118
+ */
119
+ type IndexableTagName =
120
+ | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm'
121
+ | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z'
122
+ | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M'
123
+ | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z';
124
+
125
+ /** `#a` … `#Z` — the only tag filters a relay must honour. */
126
+ export type IndexableTagFilter = `#${IndexableTagName}`;
127
+
128
+ export interface Filter extends Partial<Record<IndexableTagFilter, string[]>> {
90
129
  kinds?: number[];
91
130
  authors?: string[];
92
131
  ids?: string[];
93
132
  limit?: number;
94
133
  since?: number;
95
134
  until?: number;
96
- /** NIP-12 indexable `d`-tag filter. */
97
- '#d'?: string[];
98
- /** NIP-12 indexable single-letter tag filter. */
99
- '#t'?: string[];
100
- /** Used by OC Vote (kind 30081 ballots). */
101
- '#poll_id'?: string[];
102
- /** Used by OC Vote (kind 30081 ballots). */
103
- '#voter'?: string[];
104
- /** Used by OC Vote (kind 30080 polls). */
105
- '#creator'?: string[];
106
- /** Other indexable single-letter tags clients may filter on. */
107
- [key: `#${string}`]: string[] | undefined;
108
135
  }
109
136
 
110
137
  export interface QueryResult {
@@ -116,12 +143,24 @@ export interface QueryResult {
116
143
  // Internal — frame parsing + retry config.
117
144
  // ─────────────────────────────────────────────────────────────────────────
118
145
 
119
- type FrameType = 'OK' | 'EVENT' | 'EOSE' | 'NOTICE' | 'CLOSED';
146
+ type FrameType = 'OK' | 'EVENT' | 'EOSE' | 'NOTICE' | 'CLOSED' | 'AUTH';
120
147
  interface RelayFrame {
121
148
  type: FrameType;
122
149
  payload: unknown[];
123
150
  }
124
151
 
152
+ /**
153
+ * Sign a [NIP-42](https://nips.nostr.com/42) AUTH challenge. Given the relay's
154
+ * `challenge` string and `relayUrl`, return a signed kind-22242 event (tags
155
+ * `["relay", relayUrl]` + `["challenge", challenge]`). Passed in by the caller so
156
+ * this package stays dependency-free (no crypto here). Provide it only for
157
+ * `auth-required` relays; absent it, an AUTH challenge is ignored (today's behavior).
158
+ */
159
+ export type AuthSigner = (
160
+ challenge: string,
161
+ relayUrl: string
162
+ ) => NostrEvent | Promise<NostrEvent>;
163
+
125
164
  function parseFrame(raw: string): RelayFrame | null {
126
165
  try {
127
166
  const arr = JSON.parse(raw) as unknown[];
@@ -132,7 +171,8 @@ function parseFrame(raw: string): RelayFrame | null {
132
171
  type === 'EVENT' ||
133
172
  type === 'EOSE' ||
134
173
  type === 'NOTICE' ||
135
- type === 'CLOSED'
174
+ type === 'CLOSED' ||
175
+ type === 'AUTH'
136
176
  ) {
137
177
  return { type: type as FrameType, payload: arr.slice(1) };
138
178
  }
@@ -167,7 +207,8 @@ function delay(ms: number): Promise<void> {
167
207
  async function publishOne(
168
208
  url: string,
169
209
  event: NostrEvent,
170
- retry: RetryOptions
210
+ retry: RetryOptions,
211
+ auth?: AuthSigner
171
212
  ): Promise<PublishResult> {
172
213
  let attempts = 0;
173
214
  let backoff = retry.initialBackoffMs;
@@ -175,7 +216,7 @@ async function publishOne(
175
216
 
176
217
  while (attempts < retry.attempts) {
177
218
  attempts++;
178
- const attempt = await attemptPublish(url, event, retry.timeoutMs);
219
+ const attempt = await attemptPublish(url, event, retry.timeoutMs, auth);
179
220
  if (attempt.ok) {
180
221
  return {
181
222
  relay: url,
@@ -203,10 +244,12 @@ async function publishOne(
203
244
  function attemptPublish(
204
245
  url: string,
205
246
  event: NostrEvent,
206
- timeoutMs: number
247
+ timeoutMs: number,
248
+ auth?: AuthSigner
207
249
  ): Promise<{ ok: boolean; reason?: string; retryable: boolean }> {
208
250
  return new Promise((resolve) => {
209
251
  let settled = false;
252
+ let authed = false; // a NIP-42 AUTH handshake is attempted at most once
210
253
  let ws: WebSocket | null = null;
211
254
  const timer = setTimeout(() => {
212
255
  if (settled) return;
@@ -222,6 +265,22 @@ function attemptPublish(
222
265
  ws.onmessage = (msg) => {
223
266
  const frame = parseFrame(msg.data as string);
224
267
  if (!frame) return;
268
+ // NIP-42: an auth-required relay challenges before it accepts the
269
+ // EVENT. Sign the challenge with the caller's signer, send AUTH,
270
+ // then re-send the EVENT. Without an `auth` signer this is ignored.
271
+ if (frame.type === 'AUTH' && auth && !authed) {
272
+ authed = true;
273
+ const challenge = String(frame.payload[0] ?? '');
274
+ void Promise.resolve(auth(challenge, url))
275
+ .then((authEvent) => {
276
+ ws?.send(JSON.stringify(['AUTH', authEvent]));
277
+ ws?.send(JSON.stringify(['EVENT', event]));
278
+ })
279
+ .catch(() => {
280
+ /* signer failed → let the attempt time out / fail */
281
+ });
282
+ return;
283
+ }
225
284
  if (frame.type === 'OK' && frame.payload[0] === event.id) {
226
285
  if (settled) return;
227
286
  settled = true;
@@ -270,10 +329,11 @@ function attemptPublish(
270
329
  export async function publishEvent(
271
330
  event: NostrEvent,
272
331
  relays: readonly string[] = DEFAULT_RELAYS,
273
- timeoutMs = 5000
332
+ timeoutMs = 5000,
333
+ auth?: AuthSigner
274
334
  ): Promise<PublishResult[]> {
275
335
  const retry: RetryOptions = { ...DEFAULT_RETRY, timeoutMs };
276
- return Promise.all(relays.map((r) => publishOne(r, event, retry)));
336
+ return Promise.all(relays.map((r) => publishOne(r, event, retry, auth)));
277
337
  }
278
338
 
279
339
  // ─────────────────────────────────────────────────────────────────────────
@@ -292,7 +352,8 @@ export async function publishEvent(
292
352
  export async function queryEvents(
293
353
  filter: Filter,
294
354
  relays: readonly string[] = DEFAULT_RELAYS,
295
- timeoutMs = 1500
355
+ timeoutMs = 1500,
356
+ auth?: AuthSigner
296
357
  ): Promise<QueryResult> {
297
358
  const subId = 'ocnc-' + Math.random().toString(36).slice(2, 10);
298
359
  const byId = new Map<string, NostrEvent>();
@@ -303,6 +364,7 @@ export async function queryEvents(
303
364
  (url) =>
304
365
  new Promise<void>((resolve) => {
305
366
  let settled = false;
367
+ let authed = false; // NIP-42 handshake attempted at most once
306
368
  let count = 0;
307
369
  let reason: string | undefined;
308
370
  let ws: WebSocket | null = null;
@@ -326,6 +388,22 @@ export async function queryEvents(
326
388
  ws.onmessage = (msg) => {
327
389
  const frame = parseFrame(msg.data as string);
328
390
  if (!frame) return;
391
+ // NIP-42: auth-required relay challenges before serving the
392
+ // REQ — sign, send AUTH, then re-send the REQ. Ignored absent
393
+ // an `auth` signer (today's behavior).
394
+ if (frame.type === 'AUTH' && auth && !authed) {
395
+ authed = true;
396
+ const challenge = String(frame.payload[0] ?? '');
397
+ void Promise.resolve(auth(challenge, url))
398
+ .then((authEvent) => {
399
+ ws?.send(JSON.stringify(['AUTH', authEvent]));
400
+ ws?.send(JSON.stringify(['REQ', subId, filter]));
401
+ })
402
+ .catch(() => {
403
+ /* signer failed → let the query time out */
404
+ });
405
+ return;
406
+ }
329
407
  if (frame.type === 'EVENT' && frame.payload[0] === subId) {
330
408
  const event = frame.payload[1] as NostrEvent | undefined;
331
409
  if (event && event.id) {