signet-protocol 0.1.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.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/dist/anomaly.d.ts +42 -0
  4. package/dist/anomaly.d.ts.map +1 -0
  5. package/dist/anomaly.js +209 -0
  6. package/dist/anomaly.js.map +1 -0
  7. package/dist/badge.d.ts +56 -0
  8. package/dist/badge.d.ts.map +1 -0
  9. package/dist/badge.js +171 -0
  10. package/dist/badge.js.map +1 -0
  11. package/dist/bonds.d.ts +39 -0
  12. package/dist/bonds.d.ts.map +1 -0
  13. package/dist/bonds.js +149 -0
  14. package/dist/bonds.js.map +1 -0
  15. package/dist/challenges.d.ts +18 -0
  16. package/dist/challenges.d.ts.map +1 -0
  17. package/dist/challenges.js +145 -0
  18. package/dist/challenges.js.map +1 -0
  19. package/dist/cold-call.d.ts +74 -0
  20. package/dist/cold-call.d.ts.map +1 -0
  21. package/dist/cold-call.js +176 -0
  22. package/dist/cold-call.js.map +1 -0
  23. package/dist/compliance.d.ts +82 -0
  24. package/dist/compliance.d.ts.map +1 -0
  25. package/dist/compliance.js +478 -0
  26. package/dist/compliance.js.map +1 -0
  27. package/dist/connections.d.ts +63 -0
  28. package/dist/connections.d.ts.map +1 -0
  29. package/dist/connections.js +170 -0
  30. package/dist/connections.js.map +1 -0
  31. package/dist/constants.d.ts +86 -0
  32. package/dist/constants.d.ts.map +1 -0
  33. package/dist/constants.js +124 -0
  34. package/dist/constants.js.map +1 -0
  35. package/dist/credentials.d.ts +190 -0
  36. package/dist/credentials.d.ts.map +1 -0
  37. package/dist/credentials.js +686 -0
  38. package/dist/credentials.js.map +1 -0
  39. package/dist/crypto.d.ts +27 -0
  40. package/dist/crypto.d.ts.map +1 -0
  41. package/dist/crypto.js +75 -0
  42. package/dist/crypto.js.map +1 -0
  43. package/dist/errors.d.ts +17 -0
  44. package/dist/errors.d.ts.map +1 -0
  45. package/dist/errors.js +29 -0
  46. package/dist/errors.js.map +1 -0
  47. package/dist/i18n.d.ts +98 -0
  48. package/dist/i18n.d.ts.map +1 -0
  49. package/dist/i18n.js +1118 -0
  50. package/dist/i18n.js.map +1 -0
  51. package/dist/identity-bridge.d.ts +52 -0
  52. package/dist/identity-bridge.d.ts.map +1 -0
  53. package/dist/identity-bridge.js +228 -0
  54. package/dist/identity-bridge.js.map +1 -0
  55. package/dist/identity-tree.d.ts +47 -0
  56. package/dist/identity-tree.d.ts.map +1 -0
  57. package/dist/identity-tree.js +69 -0
  58. package/dist/identity-tree.js.map +1 -0
  59. package/dist/index.d.ts +55 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +86 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/key-derivation.d.ts +43 -0
  64. package/dist/key-derivation.d.ts.map +1 -0
  65. package/dist/key-derivation.js +212 -0
  66. package/dist/key-derivation.js.map +1 -0
  67. package/dist/lsag.d.ts +23 -0
  68. package/dist/lsag.d.ts.map +1 -0
  69. package/dist/lsag.js +35 -0
  70. package/dist/lsag.js.map +1 -0
  71. package/dist/merkle.d.ts +19 -0
  72. package/dist/merkle.d.ts.map +1 -0
  73. package/dist/merkle.js +155 -0
  74. package/dist/merkle.js.map +1 -0
  75. package/dist/policies.d.ts +22 -0
  76. package/dist/policies.d.ts.map +1 -0
  77. package/dist/policies.js +123 -0
  78. package/dist/policies.js.map +1 -0
  79. package/dist/range-proof.d.ts +6 -0
  80. package/dist/range-proof.d.ts.map +1 -0
  81. package/dist/range-proof.js +45 -0
  82. package/dist/range-proof.js.map +1 -0
  83. package/dist/relay.d.ts +106 -0
  84. package/dist/relay.d.ts.map +1 -0
  85. package/dist/relay.js +336 -0
  86. package/dist/relay.js.map +1 -0
  87. package/dist/ring-signature.d.ts +35 -0
  88. package/dist/ring-signature.d.ts.map +1 -0
  89. package/dist/ring-signature.js +56 -0
  90. package/dist/ring-signature.js.map +1 -0
  91. package/dist/shamir.d.ts +55 -0
  92. package/dist/shamir.d.ts.map +1 -0
  93. package/dist/shamir.js +253 -0
  94. package/dist/shamir.js.map +1 -0
  95. package/dist/signet-words.d.ts +42 -0
  96. package/dist/signet-words.d.ts.map +1 -0
  97. package/dist/signet-words.js +82 -0
  98. package/dist/signet-words.js.map +1 -0
  99. package/dist/store.d.ts +65 -0
  100. package/dist/store.d.ts.map +1 -0
  101. package/dist/store.js +290 -0
  102. package/dist/store.js.map +1 -0
  103. package/dist/trust-score.d.ts +9 -0
  104. package/dist/trust-score.d.ts.map +1 -0
  105. package/dist/trust-score.js +186 -0
  106. package/dist/trust-score.js.map +1 -0
  107. package/dist/types.d.ts +358 -0
  108. package/dist/types.d.ts.map +1 -0
  109. package/dist/types.js +15 -0
  110. package/dist/types.js.map +1 -0
  111. package/dist/utils.d.ts +11 -0
  112. package/dist/utils.d.ts.map +1 -0
  113. package/dist/utils.js +21 -0
  114. package/dist/utils.js.map +1 -0
  115. package/dist/validation.d.ts +33 -0
  116. package/dist/validation.d.ts.map +1 -0
  117. package/dist/validation.js +312 -0
  118. package/dist/validation.js.map +1 -0
  119. package/dist/verifiers.d.ts +18 -0
  120. package/dist/verifiers.d.ts.map +1 -0
  121. package/dist/verifiers.js +118 -0
  122. package/dist/verifiers.js.map +1 -0
  123. package/dist/vouches.d.ts +14 -0
  124. package/dist/vouches.d.ts.map +1 -0
  125. package/dist/vouches.js +103 -0
  126. package/dist/vouches.js.map +1 -0
  127. package/package.json +76 -0
  128. package/src/anomaly.ts +307 -0
  129. package/src/badge.ts +208 -0
  130. package/src/bonds.ts +203 -0
  131. package/src/challenges.ts +187 -0
  132. package/src/cold-call.ts +238 -0
  133. package/src/compliance.ts +612 -0
  134. package/src/connections.ts +216 -0
  135. package/src/constants.ts +146 -0
  136. package/src/credentials.ts +908 -0
  137. package/src/crypto.ts +85 -0
  138. package/src/errors.ts +31 -0
  139. package/src/i18n.ts +1347 -0
  140. package/src/identity-bridge.ts +262 -0
  141. package/src/identity-tree.ts +90 -0
  142. package/src/index.ts +452 -0
  143. package/src/lsag.ts +53 -0
  144. package/src/merkle.ts +176 -0
  145. package/src/policies.ts +154 -0
  146. package/src/range-proof.ts +66 -0
  147. package/src/relay.ts +433 -0
  148. package/src/ring-signature.ts +76 -0
  149. package/src/signet-words.ts +122 -0
  150. package/src/store.ts +336 -0
  151. package/src/trust-score.ts +208 -0
  152. package/src/types.ts +482 -0
  153. package/src/utils.ts +20 -0
  154. package/src/validation.ts +391 -0
  155. package/src/verifiers.ts +156 -0
  156. package/src/vouches.ts +141 -0
@@ -0,0 +1,154 @@
1
+ // Community Verification Policy (kind 30078, NIP-78)
2
+ // Create policies and check compliance
3
+
4
+ import { APP_DATA_KIND, DEFAULT_CRYPTO_ALGORITHM } from './constants.js';
5
+ import { signEvent, getPublicKey } from './crypto.js';
6
+ import { getTagValue } from './validation.js';
7
+ import { SignetValidationError } from './errors.js';
8
+ import type {
9
+ NostrEvent,
10
+ UnsignedEvent,
11
+ PolicyParams,
12
+ PolicyCheckResult,
13
+ ParsedPolicy,
14
+ SignetTier,
15
+ EnforcementLevel,
16
+ CryptoAlgorithm,
17
+ } from './types.js';
18
+
19
+ /** Build an unsigned policy event */
20
+ export function buildPolicyEvent(
21
+ operatorPubkey: string,
22
+ params: PolicyParams
23
+ ): UnsignedEvent {
24
+ const tags: string[][] = [
25
+ ['d', `signet:policy:${params.communityId}`],
26
+ ['adult-min-tier', String(params.adultMinTier)],
27
+ ['child-min-tier', String(params.childMinTier)],
28
+ ['enforcement', params.enforcement],
29
+ ['algo', DEFAULT_CRYPTO_ALGORITHM],
30
+ ];
31
+
32
+ if (params.minScore !== undefined) tags.push(['min-score', String(params.minScore)]);
33
+ if (params.modMinTier !== undefined) tags.push(['mod-min-tier', String(params.modMinTier)]);
34
+ if (params.verifierBond !== undefined) tags.push(['verifier-bond', String(params.verifierBond)]);
35
+ if (params.revocationThreshold !== undefined) tags.push(['revocation-threshold', String(params.revocationThreshold)]);
36
+
37
+ return {
38
+ kind: APP_DATA_KIND,
39
+ pubkey: operatorPubkey,
40
+ created_at: Math.floor(Date.now() / 1000),
41
+ tags,
42
+ content: params.description || '',
43
+ };
44
+ }
45
+
46
+ /** Create and sign a community policy */
47
+ export async function createPolicy(
48
+ operatorPrivateKey: string,
49
+ params: PolicyParams
50
+ ): Promise<NostrEvent> {
51
+ const pubkey = getPublicKey(operatorPrivateKey);
52
+ const event = buildPolicyEvent(pubkey, params);
53
+ return signEvent(event, operatorPrivateKey);
54
+ }
55
+
56
+ /** Parse a policy event into a structured object */
57
+ export function parsePolicy(event: NostrEvent): ParsedPolicy | null {
58
+ if (event.kind !== APP_DATA_KIND) return null;
59
+ // NIP-78 policy events are identified by the signet:policy: d-tag prefix
60
+ const dTag = getTagValue(event, 'd') || '';
61
+ if (!dTag.startsWith('signet:policy:')) return null;
62
+
63
+ const adultTier = getTagValue(event, 'adult-min-tier');
64
+ const childTier = getTagValue(event, 'child-min-tier');
65
+
66
+ const algorithm = (getTagValue(event, 'algo') || DEFAULT_CRYPTO_ALGORITHM) as CryptoAlgorithm;
67
+
68
+ // Strip 'signet:policy:' prefix from d-tag to get community ID
69
+ const communityId = dTag.slice('signet:policy:'.length);
70
+
71
+ return {
72
+ communityId,
73
+ adultMinTier: (() => { const t = adultTier ? parseInt(adultTier, 10) : NaN; return (!isNaN(t) && t >= 1 && t <= 4 ? t : 1) as SignetTier; })(),
74
+ childMinTier: (() => { const t = childTier ? parseInt(childTier, 10) : NaN; return (!isNaN(t) && t >= 1 && t <= 4 ? t : 1) as SignetTier; })(),
75
+ enforcement: (getTagValue(event, 'enforcement') || 'client') as EnforcementLevel,
76
+ minScore: (() => { const s = getTagValue(event, 'min-score'); if (!s) return undefined; const v = parseInt(s, 10); return isNaN(v) ? undefined : Math.max(0, Math.min(v, 200)); })(),
77
+ modMinTier: (() => { const s = getTagValue(event, 'mod-min-tier'); if (!s) return undefined; const t = parseInt(s, 10); if (isNaN(t) || t < 1 || t > 4) return undefined; return t as SignetTier; })(),
78
+ verifierBond: (() => { const s = getTagValue(event, 'verifier-bond'); if (!s) return undefined; const v = parseInt(s, 10); return isNaN(v) || v < 0 ? undefined : v; })(),
79
+ revocationThreshold: (() => { const s = getTagValue(event, 'revocation-threshold'); if (!s) return undefined; const v = parseInt(s, 10); return isNaN(v) || v < 1 ? undefined : v; })(),
80
+ algorithm,
81
+ };
82
+ }
83
+
84
+ /** Check if a user meets a policy's requirements */
85
+ export function checkPolicyCompliance(
86
+ policy: ParsedPolicy,
87
+ userTier: SignetTier,
88
+ userScore: number,
89
+ opts: {
90
+ isChild?: boolean;
91
+ isModerator?: boolean;
92
+ } = {}
93
+ ): PolicyCheckResult {
94
+ const requiredTier = opts.isChild
95
+ ? policy.childMinTier
96
+ : opts.isModerator && policy.modMinTier
97
+ ? policy.modMinTier
98
+ : policy.adultMinTier;
99
+
100
+ if (userTier < requiredTier) {
101
+ return {
102
+ allowed: false,
103
+ reason: `Tier ${userTier} does not meet minimum tier ${requiredTier}`,
104
+ requiredTier,
105
+ actualTier: userTier,
106
+ };
107
+ }
108
+
109
+ if (policy.minScore !== undefined && userScore < policy.minScore) {
110
+ return {
111
+ allowed: false,
112
+ reason: `Score ${userScore} does not meet minimum score ${policy.minScore}`,
113
+ requiredTier,
114
+ actualTier: userTier,
115
+ requiredScore: policy.minScore,
116
+ actualScore: userScore,
117
+ };
118
+ }
119
+
120
+ return {
121
+ allowed: true,
122
+ requiredTier,
123
+ actualTier: userTier,
124
+ requiredScore: policy.minScore,
125
+ actualScore: userScore,
126
+ };
127
+ }
128
+
129
+ /** Policy checker that holds a policy and checks multiple users */
130
+ export class PolicyChecker {
131
+ private policy: ParsedPolicy;
132
+
133
+ constructor(policyEvent: NostrEvent) {
134
+ const parsed = parsePolicy(policyEvent);
135
+ if (!parsed) throw new SignetValidationError('Invalid policy event');
136
+ this.policy = parsed;
137
+ }
138
+
139
+ getPolicy(): ParsedPolicy {
140
+ return this.policy;
141
+ }
142
+
143
+ checkAdult(tier: SignetTier, score: number): PolicyCheckResult {
144
+ return checkPolicyCompliance(this.policy, tier, score, { isChild: false });
145
+ }
146
+
147
+ checkChild(tier: SignetTier, score: number): PolicyCheckResult {
148
+ return checkPolicyCompliance(this.policy, tier, score, { isChild: true });
149
+ }
150
+
151
+ checkModerator(tier: SignetTier, score: number): PolicyCheckResult {
152
+ return checkPolicyCompliance(this.policy, tier, score, { isModerator: true });
153
+ }
154
+ }
@@ -0,0 +1,66 @@
1
+ // Pedersen Commitments + Range Proofs — compatibility wrapper over @forgesworn/range-proof
2
+ // Proves "value is in [min, max]" without revealing the exact value.
3
+ // Used for Tier 4 age range proofs: "child aged 8-12" without revealing exact age.
4
+
5
+ import {
6
+ commit,
7
+ verifyCommitment,
8
+ createRangeProof,
9
+ createAgeRangeProof,
10
+ serializeRangeProof,
11
+ deserializeRangeProof,
12
+ type PedersenCommitment,
13
+ type RangeProof,
14
+ } from '@forgesworn/range-proof';
15
+ import {
16
+ verifyRangeProof as verifyRangeProofUpstream,
17
+ verifyAgeRangeProof as verifyAgeRangeProofUpstream,
18
+ } from '@forgesworn/range-proof';
19
+
20
+ function normalizeBindingContext(bindingContext?: string): string | undefined {
21
+ return bindingContext === '' ? undefined : bindingContext;
22
+ }
23
+
24
+ function parseAgeRange(ageRange: string): { min: number; max: number } | null {
25
+ const digitsOnly = /^\d+$/;
26
+
27
+ if (ageRange.endsWith('+')) {
28
+ const minStr = ageRange.slice(0, -1);
29
+ if (!digitsOnly.test(minStr)) return null;
30
+ return { min: parseInt(minStr, 10), max: 150 };
31
+ }
32
+
33
+ const parts = ageRange.split('-');
34
+ if (parts.length !== 2) return null;
35
+ if (!digitsOnly.test(parts[0]) || !digitsOnly.test(parts[1])) return null;
36
+ return {
37
+ min: parseInt(parts[0], 10),
38
+ max: parseInt(parts[1], 10),
39
+ };
40
+ }
41
+
42
+ export { commit, verifyCommitment, createRangeProof, createAgeRangeProof, serializeRangeProof, deserializeRangeProof };
43
+ export type { PedersenCommitment, RangeProof };
44
+
45
+ export function verifyRangeProof(
46
+ proof: RangeProof,
47
+ expectedMin: number,
48
+ expectedMax: number,
49
+ expectedBindingContext?: string
50
+ ): boolean {
51
+ if (!Number.isSafeInteger(expectedMin) || !Number.isSafeInteger(expectedMax)) return false;
52
+ if (expectedMin < 0 || expectedMax < 0 || expectedMax < expectedMin) return false;
53
+ if (proof.min !== expectedMin || proof.max !== expectedMax) return false;
54
+ if (normalizeBindingContext(proof.context) !== normalizeBindingContext(expectedBindingContext)) return false;
55
+ return verifyRangeProofUpstream(proof, expectedMin, expectedMax, expectedBindingContext);
56
+ }
57
+
58
+ export function verifyAgeRangeProof(
59
+ proof: RangeProof,
60
+ expectedAgeRange: string,
61
+ expectedSubjectPubkey?: string
62
+ ): boolean {
63
+ const parsed = parseAgeRange(expectedAgeRange);
64
+ if (!parsed) return false;
65
+ return verifyRangeProof(proof, parsed.min, parsed.max, expectedSubjectPubkey);
66
+ }
package/src/relay.ts ADDED
@@ -0,0 +1,433 @@
1
+ // Nostr Relay Client
2
+ // WebSocket-based publish/subscribe with NIP-42 AUTH support
3
+
4
+ import { signEvent, getPublicKey, verifyEvent } from './crypto.js';
5
+ import type { NostrEvent, UnsignedEvent } from './types.js';
6
+ import { SignetValidationError } from './errors.js';
7
+ import { validateFieldSizeBounds } from './validation.js';
8
+
9
+ /** NIP-42 client authentication event kind */
10
+ const NIP42_AUTH_KIND = 22242;
11
+
12
+ /** Maximum WebSocket message size (1 MB) — prevents DoS via oversized relay messages */
13
+ const MAX_MESSAGE_SIZE = 1_048_576;
14
+
15
+ /** Nostr relay message types (relay → client) */
16
+ export type RelayMessage =
17
+ | ['EVENT', string, NostrEvent]
18
+ | ['OK', string, boolean, string]
19
+ | ['EOSE', string]
20
+ | ['NOTICE', string]
21
+ | ['AUTH', string];
22
+
23
+ /** Nostr subscription filter */
24
+ export interface NostrFilter {
25
+ ids?: string[];
26
+ authors?: string[];
27
+ kinds?: number[];
28
+ '#d'?: string[];
29
+ '#p'?: string[];
30
+ '#L'?: string[];
31
+ '#l'?: string[];
32
+ since?: number;
33
+ until?: number;
34
+ limit?: number;
35
+ }
36
+
37
+ /** Subscription callback */
38
+ export type SubscriptionCallback = (event: NostrEvent) => void;
39
+
40
+ /** Relay connection state */
41
+ export type RelayState = 'connecting' | 'connected' | 'disconnected' | 'error';
42
+
43
+ /** Options for the relay client */
44
+ export interface RelayOptions {
45
+ /** Private key for NIP-42 AUTH (hex) */
46
+ authPrivateKey?: string;
47
+ /** Connection timeout in ms (default: 5000) */
48
+ connectTimeout?: number;
49
+ /** Auto-reconnect on disconnect (default: true) */
50
+ autoReconnect?: boolean;
51
+ /** Reconnect delay in ms (default: 3000) */
52
+ reconnectDelay?: number;
53
+ /** Max reconnect attempts (default: 5) */
54
+ maxReconnectAttempts?: number;
55
+ /** Verify event signatures before delivering to callbacks (default: true).
56
+ * Events that fail verification are silently dropped. */
57
+ verifyEvents?: boolean;
58
+ /** Callback for rejected events (signature or ID verification failed) */
59
+ onEventRejected?: (event: NostrEvent, reason: string) => void;
60
+ }
61
+
62
+ interface PendingSubscription {
63
+ filters: NostrFilter[];
64
+ callback: SubscriptionCallback;
65
+ eoseCallback?: () => void;
66
+ }
67
+
68
+ interface PendingPublish {
69
+ resolve: (result: { ok: boolean; message: string }) => void;
70
+ timeout: ReturnType<typeof setTimeout>;
71
+ }
72
+
73
+ /**
74
+ * Nostr relay client with NIP-42 AUTH support.
75
+ * Handles publishing events, subscribing to filters, and authentication.
76
+ */
77
+ export class RelayClient {
78
+ private ws: WebSocket | null = null;
79
+ private state: RelayState = 'disconnected';
80
+ private subscriptions = new Map<string, PendingSubscription>();
81
+ private pendingPublishes = new Map<string, PendingPublish>();
82
+ private subCounter = 0;
83
+ private reconnectAttempts = 0;
84
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
85
+ private disconnectRequested = false;
86
+ private onStateChange?: (state: RelayState) => void;
87
+
88
+ constructor(
89
+ private url: string,
90
+ private options: RelayOptions = {}
91
+ ) {
92
+ if (!/^wss?:\/\//i.test(this.url)) {
93
+ throw new SignetValidationError('Relay URL must use ws:// or wss:// scheme');
94
+ }
95
+ // Enforce TLS for non-localhost connections — identity data must not travel in cleartext
96
+ if (/^ws:\/\//i.test(this.url) && !/^ws:\/\/(localhost|127\.0\.0\.1)([:\/]|$)/i.test(this.url)) {
97
+ throw new SignetValidationError('Relay URL must use wss:// for non-localhost connections');
98
+ }
99
+ this.options = {
100
+ connectTimeout: 5000,
101
+ autoReconnect: true,
102
+ reconnectDelay: 3000,
103
+ maxReconnectAttempts: 5,
104
+ verifyEvents: true,
105
+ ...options,
106
+ };
107
+ }
108
+
109
+ /** Get current connection state */
110
+ getState(): RelayState {
111
+ return this.state;
112
+ }
113
+
114
+ /** Set a state change listener */
115
+ onStateChanged(callback: (state: RelayState) => void): void {
116
+ this.onStateChange = callback;
117
+ }
118
+
119
+ private setState(state: RelayState): void {
120
+ this.state = state;
121
+ this.onStateChange?.(state);
122
+ }
123
+
124
+ /** Connect to the relay */
125
+ connect(): Promise<void> {
126
+ return new Promise((resolve, reject) => {
127
+ if (this.state === 'connected') {
128
+ resolve();
129
+ return;
130
+ }
131
+
132
+ this.setState('connecting');
133
+
134
+ const timeout = setTimeout(() => {
135
+ this.ws?.close();
136
+ reject(new SignetValidationError(`Connection timeout after ${this.options.connectTimeout}ms`));
137
+ }, this.options.connectTimeout);
138
+
139
+ this.ws = new WebSocket(this.url);
140
+
141
+ this.ws.onopen = () => {
142
+ clearTimeout(timeout);
143
+ this.setState('connected');
144
+ this.reconnectAttempts = 0;
145
+ // Re-subscribe existing subscriptions
146
+ for (const [id, sub] of this.subscriptions) {
147
+ this.sendSubscription(id, sub.filters);
148
+ }
149
+ resolve();
150
+ };
151
+
152
+ this.ws.onclose = () => {
153
+ clearTimeout(timeout);
154
+ this.setState('disconnected');
155
+ this.handleReconnect();
156
+ };
157
+
158
+ this.ws.onerror = () => {
159
+ clearTimeout(timeout);
160
+ this.setState('error');
161
+ reject(new SignetValidationError('WebSocket connection failed'));
162
+ };
163
+
164
+ this.ws.onmessage = (msg) => {
165
+ if (typeof msg.data !== 'string') return; // ignore binary frames
166
+ this.handleMessage(msg.data);
167
+ };
168
+ });
169
+ }
170
+
171
+ /** Disconnect from the relay */
172
+ disconnect(): void {
173
+ if (this.reconnectTimer) {
174
+ clearTimeout(this.reconnectTimer);
175
+ this.reconnectTimer = null;
176
+ }
177
+ this.disconnectRequested = true;
178
+ this.ws?.close();
179
+ this.ws = null;
180
+ this.setState('disconnected');
181
+
182
+ // Clean up pending publishes
183
+ for (const [, pending] of this.pendingPublishes) {
184
+ clearTimeout(pending.timeout);
185
+ pending.resolve({ ok: false, message: 'Disconnected' });
186
+ }
187
+ this.pendingPublishes.clear();
188
+ }
189
+
190
+ /** Publish an event to the relay */
191
+ async publish(event: NostrEvent): Promise<{ ok: boolean; message: string }> {
192
+ if (this.state !== 'connected' || !this.ws) {
193
+ throw new SignetValidationError('Not connected to relay');
194
+ }
195
+ if (!/^[0-9a-f]{64}$/.test(event.id)) {
196
+ throw new SignetValidationError('Invalid event ID: must be a 64-character lowercase hex string');
197
+ }
198
+
199
+ return new Promise((resolve) => {
200
+ const timeout = setTimeout(() => {
201
+ this.pendingPublishes.delete(event.id);
202
+ resolve({ ok: false, message: 'Publish timeout' });
203
+ }, 10000);
204
+
205
+ this.pendingPublishes.set(event.id, { resolve, timeout });
206
+ this.ws!.send(JSON.stringify(['EVENT', event]));
207
+ });
208
+ }
209
+
210
+ /**
211
+ * Subscribe to events matching the given filters.
212
+ * @returns Subscription ID (use to close the subscription)
213
+ */
214
+ subscribe(
215
+ filters: NostrFilter[],
216
+ onEvent: SubscriptionCallback,
217
+ onEose?: () => void
218
+ ): string {
219
+ const subId = `signet-sub-${++this.subCounter}`;
220
+
221
+ this.subscriptions.set(subId, {
222
+ filters,
223
+ callback: onEvent,
224
+ eoseCallback: onEose,
225
+ });
226
+
227
+ if (this.state === 'connected') {
228
+ this.sendSubscription(subId, filters);
229
+ }
230
+
231
+ return subId;
232
+ }
233
+
234
+ /** Close a subscription */
235
+ closeSubscription(subId: string): void {
236
+ this.subscriptions.delete(subId);
237
+ if (this.state === 'connected' && this.ws) {
238
+ this.ws.send(JSON.stringify(['CLOSE', subId]));
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Fetch events matching filters (returns after EOSE).
244
+ * Convenience method that subscribes, collects events, and closes.
245
+ */
246
+ fetch(filters: NostrFilter[], timeoutMs: number = 30000): Promise<NostrEvent[]> {
247
+ return new Promise((resolve) => {
248
+ const events: NostrEvent[] = [];
249
+ let resolved = false;
250
+
251
+ const done = () => {
252
+ if (resolved) return;
253
+ resolved = true;
254
+ clearTimeout(timer);
255
+ this.closeSubscription(subId);
256
+ resolve(events);
257
+ };
258
+
259
+ const maxEvents = 10_000;
260
+ const subId = this.subscribe(
261
+ filters,
262
+ (event) => { events.push(event); if (events.length >= maxEvents) done(); },
263
+ () => done(),
264
+ );
265
+
266
+ // Timeout guard: resolve with events collected so far if EOSE never arrives
267
+ const timer = setTimeout(() => done(), timeoutMs);
268
+ });
269
+ }
270
+
271
+ private sendSubscription(subId: string, filters: NostrFilter[]): void {
272
+ if (this.ws) {
273
+ this.ws.send(JSON.stringify(['REQ', subId, ...filters]));
274
+ }
275
+ }
276
+
277
+ private handleMessage(data: string): void {
278
+ if (data.length > MAX_MESSAGE_SIZE) return;
279
+ try {
280
+ const msg: unknown = JSON.parse(data);
281
+ if (!Array.isArray(msg) || msg.length < 2 || typeof msg[0] !== 'string') return;
282
+
283
+ switch (msg[0]) {
284
+ case 'EVENT': {
285
+ if (msg.length < 3 || typeof msg[1] !== 'string' || typeof msg[2] !== 'object' || msg[2] === null) break;
286
+ const raw = msg[2] as Record<string, unknown>;
287
+ // Validate required NostrEvent fields before casting
288
+ if (typeof raw.id !== 'string' || typeof raw.pubkey !== 'string' ||
289
+ typeof raw.kind !== 'number' || typeof raw.created_at !== 'number' ||
290
+ !Array.isArray(raw.tags) || typeof raw.content !== 'string' ||
291
+ typeof raw.sig !== 'string') break;
292
+ const subId = msg[1] as string;
293
+ const event = raw as unknown as NostrEvent;
294
+ const boundsErrors: string[] = [];
295
+ validateFieldSizeBounds(event, boundsErrors);
296
+ if (boundsErrors.length > 0) {
297
+ break; // reject oversized events
298
+ }
299
+ const sub = this.subscriptions.get(subId);
300
+ if (sub) {
301
+ if (this.options.verifyEvents !== false) {
302
+ verifyEvent(event).then((valid) => {
303
+ if (valid) {
304
+ sub.callback(event);
305
+ } else {
306
+ this.options.onEventRejected?.(event, 'invalid signature or event ID');
307
+ }
308
+ });
309
+ } else {
310
+ sub.callback(event);
311
+ }
312
+ }
313
+ break;
314
+ }
315
+
316
+ case 'OK': {
317
+ if (msg.length < 4 || typeof msg[1] !== 'string' || typeof msg[2] !== 'boolean' || typeof msg[3] !== 'string') break;
318
+ const eventId = msg[1] as string;
319
+ const pending = this.pendingPublishes.get(eventId);
320
+ if (pending) {
321
+ clearTimeout(pending.timeout);
322
+ this.pendingPublishes.delete(eventId);
323
+ pending.resolve({ ok: msg[2] as boolean, message: msg[3] as string });
324
+ }
325
+ break;
326
+ }
327
+
328
+ case 'EOSE': {
329
+ if (typeof msg[1] !== 'string') break;
330
+ const sub = this.subscriptions.get(msg[1]);
331
+ sub?.eoseCallback?.();
332
+ break;
333
+ }
334
+
335
+ case 'AUTH': {
336
+ if (typeof msg[1] !== 'string') break;
337
+ // Cap challenge length to prevent oversized AUTH events from malicious relays
338
+ if (msg[1].length > 256) break;
339
+ this.handleAuth(msg[1]);
340
+ break;
341
+ }
342
+
343
+ case 'NOTICE': {
344
+ // Relay notices are informational — log but don't act
345
+ break;
346
+ }
347
+ }
348
+ } catch {
349
+ // Malformed message — ignore
350
+ }
351
+ }
352
+
353
+ /** Handle NIP-42 AUTH challenge */
354
+ private async handleAuth(challenge: string): Promise<void> {
355
+ if (!this.options.authPrivateKey) return;
356
+
357
+ const pubkey = getPublicKey(this.options.authPrivateKey);
358
+ const authEvent: UnsignedEvent = {
359
+ kind: NIP42_AUTH_KIND,
360
+ pubkey,
361
+ created_at: Math.floor(Date.now() / 1000),
362
+ tags: [
363
+ ['relay', this.url],
364
+ ['challenge', challenge],
365
+ ],
366
+ content: '',
367
+ };
368
+
369
+ const signed = await signEvent(authEvent, this.options.authPrivateKey);
370
+ this.ws?.send(JSON.stringify(['AUTH', signed]));
371
+ }
372
+
373
+ private handleReconnect(): void {
374
+ if (this.disconnectRequested || !this.options.autoReconnect) return;
375
+ if (this.reconnectAttempts >= (this.options.maxReconnectAttempts ?? 5)) return;
376
+
377
+ this.reconnectAttempts++;
378
+ this.reconnectTimer = setTimeout(() => {
379
+ this.connect().catch(() => {
380
+ // Will retry via onclose handler
381
+ });
382
+ }, this.options.reconnectDelay);
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Publish a Signet event to multiple relays.
388
+ *
389
+ * WARNING: Relay URLs are accepted as-is. Callers are responsible for
390
+ * validating that URLs come from trusted sources and do not encode credentials.
391
+ * The RelayClient constructor enforces wss:// for non-localhost connections.
392
+ */
393
+ export async function publishToRelays(
394
+ event: NostrEvent,
395
+ relayUrls: string[]
396
+ ): Promise<Map<string, { ok: boolean; message: string }>> {
397
+ const results = new Map<string, { ok: boolean; message: string }>();
398
+
399
+ const promises = relayUrls.map(async (url) => {
400
+ const relay = new RelayClient(url);
401
+ try {
402
+ await relay.connect();
403
+ const result = await relay.publish(event);
404
+ results.set(url, result);
405
+ } catch (err) {
406
+ results.set(url, { ok: false, message: err instanceof Error ? err.message : 'Connection failed' });
407
+ } finally {
408
+ relay.disconnect();
409
+ }
410
+ });
411
+
412
+ await Promise.allSettled(promises);
413
+ return results;
414
+ }
415
+
416
+ /**
417
+ * Fetch Signet events from a relay by kind and optional filters.
418
+ *
419
+ * WARNING: Relay URL is accepted as-is. Callers are responsible for
420
+ * validating that URLs come from trusted sources and do not encode credentials.
421
+ */
422
+ export async function fetchFromRelay(
423
+ relayUrl: string,
424
+ filters: NostrFilter[]
425
+ ): Promise<NostrEvent[]> {
426
+ const relay = new RelayClient(relayUrl);
427
+ try {
428
+ await relay.connect();
429
+ return await relay.fetch(filters);
430
+ } finally {
431
+ relay.disconnect();
432
+ }
433
+ }