nostr-bray 0.0.0-development

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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +166 -0
  3. package/dist/cli.d.ts +20 -0
  4. package/dist/cli.js +229 -0
  5. package/dist/config.d.ts +5 -0
  6. package/dist/config.js +87 -0
  7. package/dist/context.d.ts +35 -0
  8. package/dist/context.js +184 -0
  9. package/dist/identity/handlers.d.ts +31 -0
  10. package/dist/identity/handlers.js +32 -0
  11. package/dist/identity/migration.d.ts +37 -0
  12. package/dist/identity/migration.js +84 -0
  13. package/dist/identity/shamir.d.ts +18 -0
  14. package/dist/identity/shamir.js +46 -0
  15. package/dist/identity/tools.d.ts +11 -0
  16. package/dist/identity/tools.js +168 -0
  17. package/dist/index.d.ts +10 -0
  18. package/dist/index.js +125 -0
  19. package/dist/nip65.d.ts +15 -0
  20. package/dist/nip65.js +71 -0
  21. package/dist/relay/handlers.d.ts +36 -0
  22. package/dist/relay/handlers.js +96 -0
  23. package/dist/relay/tools.d.ts +3 -0
  24. package/dist/relay/tools.js +60 -0
  25. package/dist/relay-pool.d.ts +42 -0
  26. package/dist/relay-pool.js +150 -0
  27. package/dist/safety/handlers.d.ts +15 -0
  28. package/dist/safety/handlers.js +12 -0
  29. package/dist/safety/tools.d.ts +3 -0
  30. package/dist/safety/tools.js +29 -0
  31. package/dist/social/dm.d.ts +28 -0
  32. package/dist/social/dm.js +90 -0
  33. package/dist/social/handlers.d.ts +42 -0
  34. package/dist/social/handlers.js +107 -0
  35. package/dist/social/notifications.d.ts +32 -0
  36. package/dist/social/notifications.js +74 -0
  37. package/dist/social/tools.d.ts +3 -0
  38. package/dist/social/tools.js +168 -0
  39. package/dist/trust/handlers.d.ts +65 -0
  40. package/dist/trust/handlers.js +133 -0
  41. package/dist/trust/ring.d.ts +19 -0
  42. package/dist/trust/ring.js +54 -0
  43. package/dist/trust/spoken.d.ts +33 -0
  44. package/dist/trust/spoken.js +14 -0
  45. package/dist/trust/tools.d.ts +3 -0
  46. package/dist/trust/tools.js +182 -0
  47. package/dist/types.d.ts +41 -0
  48. package/dist/types.js +1 -0
  49. package/dist/validation.d.ts +5 -0
  50. package/dist/validation.js +5 -0
  51. package/dist/zap/handlers.d.ts +80 -0
  52. package/dist/zap/handlers.js +165 -0
  53. package/dist/zap/tools.d.ts +3 -0
  54. package/dist/zap/tools.js +98 -0
  55. package/llms-full.txt +281 -0
  56. package/llms.txt +73 -0
  57. package/package.json +96 -0
package/dist/index.js ADDED
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { loadConfig } from './config.js';
4
+ import { IdentityContext } from './context.js';
5
+ import { RelayPool } from './relay-pool.js';
6
+ import { Nip65Manager } from './nip65.js';
7
+ import { registerIdentityTools } from './identity/tools.js';
8
+ import { registerSocialTools } from './social/tools.js';
9
+ import { registerTrustTools } from './trust/tools.js';
10
+ import { registerRelayTools } from './relay/tools.js';
11
+ import { registerZapTools } from './zap/tools.js';
12
+ import { registerSafetyTools } from './safety/tools.js';
13
+ const config = loadConfig();
14
+ const pool = new RelayPool({
15
+ torProxy: config.torProxy,
16
+ allowClearnet: config.allowClearnetWithTor || !config.torProxy,
17
+ defaultRelays: config.relays,
18
+ });
19
+ const nip65 = new Nip65Manager(pool, config.relays);
20
+ const ctx = new IdentityContext(config.secretKey, config.secretFormat);
21
+ export const deps = { ctx, pool, nip65, nwcUri: config.nwcUri };
22
+ config.secretKey = '';
23
+ config.nwcUri = undefined;
24
+ // Load master identity relay list
25
+ const masterRelays = await nip65.loadForIdentity(ctx.activeNpub);
26
+ pool.reconfigure(ctx.activeNpub, masterRelays);
27
+ const server = new McpServer({ name: 'nostr-bray', version: '0.1.0' });
28
+ // Phase 2: Identity tools
29
+ registerIdentityTools(server, deps);
30
+ // Phase 3: Social tools
31
+ registerSocialTools(server, deps);
32
+ // Phase 4: Trust tools
33
+ registerTrustTools(server, deps);
34
+ // Phase 5: Relay, Zap & Safety tools
35
+ registerRelayTools(server, deps);
36
+ registerZapTools(server, deps);
37
+ registerSafetyTools(server, deps);
38
+ if (config.transport === 'stdio') {
39
+ const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
40
+ await server.connect(new StdioServerTransport());
41
+ console.error('nostr-bray started (stdio)');
42
+ }
43
+ else {
44
+ const { createServer } = await import('node:http');
45
+ const { StreamableHTTPServerTransport } = await import('@modelcontextprotocol/sdk/server/streamableHttp.js');
46
+ const { randomUUID } = await import('node:crypto');
47
+ const token = randomUUID();
48
+ console.error(`nostr-bray HTTP auth token: ${token}`);
49
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
50
+ await server.connect(transport);
51
+ const { timingSafeEqual } = await import('node:crypto');
52
+ const expectedAuth = Buffer.from(`Bearer ${token}`);
53
+ // Sliding window rate limiter (per-IP)
54
+ const rateLimits = new Map();
55
+ const RATE_WINDOW = 60_000; // 60 seconds
56
+ const RATE_LIMIT = 100; // 100 requests per window
57
+ function checkRateLimit(ip) {
58
+ const now = Date.now();
59
+ const entry = rateLimits.get(ip);
60
+ if (!entry || now > entry.resetAt) {
61
+ rateLimits.set(ip, { count: 1, resetAt: now + RATE_WINDOW });
62
+ return true;
63
+ }
64
+ entry.count++;
65
+ return entry.count <= RATE_LIMIT;
66
+ }
67
+ const httpServer = createServer(async (req, res) => {
68
+ const clientIp = req.socket.remoteAddress ?? 'unknown';
69
+ // Rate limiting
70
+ if (!checkRateLimit(clientIp)) {
71
+ res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '60' });
72
+ res.end(JSON.stringify({ error: 'Too many requests' }));
73
+ return;
74
+ }
75
+ // Bearer token auth (constant-time comparison)
76
+ const actual = Buffer.from(req.headers.authorization ?? '');
77
+ if (actual.length !== expectedAuth.length || !timingSafeEqual(actual, expectedAuth)) {
78
+ res.writeHead(401, { 'Content-Type': 'application/json' });
79
+ res.end(JSON.stringify({ error: 'Unauthorised' }));
80
+ return;
81
+ }
82
+ // Security headers — no CORS headers = deny all cross-origin
83
+ res.setHeader('X-Content-Type-Options', 'nosniff');
84
+ res.setHeader('X-Frame-Options', 'DENY');
85
+ if (req.url === '/health') {
86
+ res.writeHead(200, { 'Content-Type': 'application/json' });
87
+ res.end(JSON.stringify({ status: 'ok' }));
88
+ return;
89
+ }
90
+ // Parse body for POST requests (1MB limit)
91
+ if (req.method === 'POST') {
92
+ const MAX_BODY = 1_048_576;
93
+ const chunks = [];
94
+ let size = 0;
95
+ for await (const chunk of req) {
96
+ size += chunk.length;
97
+ if (size > MAX_BODY) {
98
+ res.writeHead(413, { 'Content-Type': 'application/json' });
99
+ res.end(JSON.stringify({ error: 'Request body too large' }));
100
+ return;
101
+ }
102
+ chunks.push(chunk);
103
+ }
104
+ let body;
105
+ try {
106
+ body = JSON.parse(Buffer.concat(chunks).toString());
107
+ }
108
+ catch {
109
+ res.writeHead(400, { 'Content-Type': 'application/json' });
110
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
111
+ return;
112
+ }
113
+ await transport.handleRequest(req, res, body);
114
+ }
115
+ else {
116
+ await transport.handleRequest(req, res);
117
+ }
118
+ });
119
+ httpServer.listen(config.port, config.bindAddress, () => {
120
+ console.error(`nostr-bray HTTP on ${config.bindAddress}:${config.port}`);
121
+ });
122
+ }
123
+ const shutdown = () => { ctx.destroy(); pool.close(); process.exit(0); };
124
+ process.on('SIGINT', shutdown);
125
+ process.on('SIGTERM', shutdown);
@@ -0,0 +1,15 @@
1
+ import type { RelayPool } from './relay-pool.js';
2
+ import type { RelaySet } from './types.js';
3
+ /** Parse NIP-65 r-tags into read/write relay sets, filtering invalid URLs */
4
+ export declare function parseRelayTags(tags: string[][]): RelaySet;
5
+ export declare class Nip65Manager {
6
+ private pool;
7
+ private defaults;
8
+ private cache;
9
+ private readonly TTL;
10
+ constructor(pool: RelayPool, defaultRelays: string[]);
11
+ /** Load relay list for an identity from kind 10002 events */
12
+ loadForIdentity(npub: string, pubkeyHex?: string): Promise<RelaySet>;
13
+ /** Get cached relay list for an identity */
14
+ getCached(npub: string): RelaySet | undefined;
15
+ }
package/dist/nip65.js ADDED
@@ -0,0 +1,71 @@
1
+ import { decode } from 'nostr-tools/nip19';
2
+ import { verifyEvent } from 'nostr-tools/pure';
3
+ /** Validate relay URL scheme — must be wss:// or ws:// */
4
+ function isValidRelayUrl(url) {
5
+ return /^wss?:\/\//i.test(url);
6
+ }
7
+ /** Parse NIP-65 r-tags into read/write relay sets, filtering invalid URLs */
8
+ export function parseRelayTags(tags) {
9
+ const read = [];
10
+ const write = [];
11
+ for (const tag of tags) {
12
+ if (tag[0] !== 'r' || !tag[1])
13
+ continue;
14
+ const url = tag[1];
15
+ if (!isValidRelayUrl(url))
16
+ continue; // reject non-wss/ws URLs
17
+ const marker = tag[2];
18
+ if (marker === 'read') {
19
+ read.push(url);
20
+ }
21
+ else if (marker === 'write') {
22
+ write.push(url);
23
+ }
24
+ else {
25
+ read.push(url);
26
+ write.push(url);
27
+ }
28
+ }
29
+ return { read, write };
30
+ }
31
+ export class Nip65Manager {
32
+ pool;
33
+ defaults;
34
+ cache = new Map();
35
+ TTL = 3600_000; // 1 hour
36
+ constructor(pool, defaultRelays) {
37
+ this.pool = pool;
38
+ this.defaults = defaultRelays;
39
+ }
40
+ /** Load relay list for an identity from kind 10002 events */
41
+ async loadForIdentity(npub, pubkeyHex) {
42
+ // Return cached if still fresh
43
+ const cached = this.cache.get(npub);
44
+ if (cached && Date.now() - cached.fetchedAt < this.TTL)
45
+ return cached.relays;
46
+ const hex = pubkeyHex ?? decode(npub).data;
47
+ const events = await this.pool.query(npub, {
48
+ kinds: [10002],
49
+ authors: [hex],
50
+ });
51
+ // Filter to verified events from the expected author
52
+ const verified = events.filter(e => e.pubkey === hex && verifyEvent(e));
53
+ if (verified.length === 0) {
54
+ const fallback = {
55
+ read: [...this.defaults],
56
+ write: [...this.defaults],
57
+ };
58
+ this.cache.set(npub, { relays: fallback, fetchedAt: Date.now() });
59
+ return fallback;
60
+ }
61
+ // Take highest created_at from verified events
62
+ const best = verified.reduce((a, b) => b.created_at > a.created_at ? b : a);
63
+ const relaySet = parseRelayTags(best.tags);
64
+ this.cache.set(npub, { relays: relaySet, fetchedAt: Date.now() });
65
+ return relaySet;
66
+ }
67
+ /** Get cached relay list for an identity */
68
+ getCached(npub) {
69
+ return this.cache.get(npub)?.relays;
70
+ }
71
+ }
@@ -0,0 +1,36 @@
1
+ import type { Event as NostrEvent } from 'nostr-tools';
2
+ import type { IdentityContext } from '../context.js';
3
+ import type { RelayPool } from '../relay-pool.js';
4
+ import type { PublishResult } from '../types.js';
5
+ export interface RelayListResult {
6
+ read: string[];
7
+ write: string[];
8
+ sharedWarning?: string;
9
+ }
10
+ export interface RelayEntry {
11
+ url: string;
12
+ mode?: 'read' | 'write';
13
+ }
14
+ /** Get relay list for the active identity, optionally checking for shared relays with another identity */
15
+ export declare function handleRelayList(ctx: IdentityContext, pool: RelayPool, compareWithNpub?: string): RelayListResult;
16
+ /** Publish a kind 10002 relay list */
17
+ export declare function handleRelaySet(ctx: IdentityContext, pool: RelayPool, args: {
18
+ relays: RelayEntry[];
19
+ confirm?: boolean;
20
+ }): Promise<{
21
+ event: NostrEvent;
22
+ published: boolean;
23
+ publish?: PublishResult;
24
+ warning?: string;
25
+ }>;
26
+ /** Add a single relay to the active identity's relay set */
27
+ export declare function handleRelayAdd(ctx: IdentityContext, pool: RelayPool, args: {
28
+ url: string;
29
+ mode?: 'read' | 'write';
30
+ }): {
31
+ reconfigured: boolean;
32
+ };
33
+ /** Validate a relay URL — must be wss:// or ws://, no private networks */
34
+ export declare function validateRelayUrl(url: string): void;
35
+ /** Fetch NIP-11 relay information document */
36
+ export declare function handleRelayInfo(url: string): Promise<Record<string, unknown>>;
@@ -0,0 +1,96 @@
1
+ /** Get relay list for the active identity, optionally checking for shared relays with another identity */
2
+ export function handleRelayList(ctx, pool, compareWithNpub) {
3
+ const relays = pool.getRelays(ctx.activeNpub);
4
+ const result = {
5
+ read: relays.read,
6
+ write: relays.write,
7
+ };
8
+ if (compareWithNpub) {
9
+ const shared = pool.checkSharedRelays(ctx.activeNpub, compareWithNpub);
10
+ if (shared.length > 0) {
11
+ result.sharedWarning = `Shared relays with ${compareWithNpub}: ${shared.join(', ')}. This may link identities.`;
12
+ }
13
+ }
14
+ return result;
15
+ }
16
+ /** Publish a kind 10002 relay list */
17
+ export async function handleRelaySet(ctx, pool, args) {
18
+ // Check for existing relay list
19
+ const existing = await pool.query(ctx.activeNpub, {
20
+ kinds: [10002],
21
+ authors: [ctx.activeNpub],
22
+ });
23
+ // Build r-tags
24
+ const tags = args.relays.map(r => {
25
+ if (r.mode)
26
+ return ['r', r.url, r.mode];
27
+ return ['r', r.url];
28
+ });
29
+ const sign = ctx.getSigningFunction();
30
+ const event = await sign({
31
+ kind: 10002,
32
+ created_at: Math.floor(Date.now() / 1000),
33
+ tags,
34
+ content: '',
35
+ });
36
+ if (existing.length > 0 && !args.confirm) {
37
+ return {
38
+ event,
39
+ published: false,
40
+ warning: 'Relay list already exists. Set confirm: true to overwrite.',
41
+ };
42
+ }
43
+ const publish = await pool.publish(ctx.activeNpub, event);
44
+ // Reconfigure the pool with the new relay set
45
+ const read = args.relays.filter(r => !r.mode || r.mode === 'read').map(r => r.url);
46
+ const write = args.relays.filter(r => !r.mode || r.mode === 'write').map(r => r.url);
47
+ pool.reconfigure(ctx.activeNpub, { read, write });
48
+ return { event, published: true, publish };
49
+ }
50
+ /** Add a single relay to the active identity's relay set */
51
+ export function handleRelayAdd(ctx, pool, args) {
52
+ const current = pool.getRelays(ctx.activeNpub);
53
+ const read = [...current.read];
54
+ const write = [...current.write];
55
+ if (!args.mode || args.mode === 'read') {
56
+ if (!read.includes(args.url))
57
+ read.push(args.url);
58
+ }
59
+ if (!args.mode || args.mode === 'write') {
60
+ if (!write.includes(args.url))
61
+ write.push(args.url);
62
+ }
63
+ pool.reconfigure(ctx.activeNpub, { read, write });
64
+ return { reconfigured: true };
65
+ }
66
+ /** Validate a relay URL — must be wss:// or ws://, no private networks */
67
+ export function validateRelayUrl(url) {
68
+ if (!/^wss?:\/\//i.test(url)) {
69
+ throw new Error('Relay URL must use wss:// or ws:// scheme');
70
+ }
71
+ const parsed = new URL(url);
72
+ const host = parsed.hostname.toLowerCase();
73
+ if (host === 'localhost' || host === '[::1]' ||
74
+ host.startsWith('127.') || host.startsWith('10.') ||
75
+ host.startsWith('192.168.') || host === '169.254.169.254' ||
76
+ /^172\.(1[6-9]|2\d|3[01])\./.test(host)) {
77
+ throw new Error('Relay URL must not point to private network addresses');
78
+ }
79
+ }
80
+ /** Fetch NIP-11 relay information document */
81
+ export async function handleRelayInfo(url) {
82
+ validateRelayUrl(url);
83
+ const httpUrl = url.replace(/^wss:\/\//, 'https://').replace(/^ws:\/\//, 'http://');
84
+ const response = await fetch(httpUrl, {
85
+ headers: { Accept: 'application/nostr+json' },
86
+ signal: AbortSignal.timeout(10_000),
87
+ });
88
+ if (!response.ok) {
89
+ throw new Error(`NIP-11 fetch failed: ${response.status} ${response.statusText}`);
90
+ }
91
+ const text = await response.text();
92
+ if (text.length > 1_048_576) {
93
+ throw new Error('Relay info document too large');
94
+ }
95
+ return JSON.parse(text);
96
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { ToolDeps } from '../identity/tools.js';
3
+ export declare function registerRelayTools(server: McpServer, deps: ToolDeps): void;
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod';
2
+ import { relayUrl } from '../validation.js';
3
+ import { handleRelayList, handleRelaySet, handleRelayAdd, handleRelayInfo } from './handlers.js';
4
+ export function registerRelayTools(server, deps) {
5
+ server.registerTool('relay_list', {
6
+ description: 'List the relay set (read/write) for the active identity. Optionally check for shared relays with another identity.',
7
+ inputSchema: {
8
+ compareWithNpub: z.string().optional().describe('Compare shared relays with this npub'),
9
+ },
10
+ annotations: { readOnlyHint: true },
11
+ }, async ({ compareWithNpub }) => {
12
+ const result = handleRelayList(deps.ctx, deps.pool, compareWithNpub);
13
+ return {
14
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
15
+ };
16
+ });
17
+ server.registerTool('relay_set', {
18
+ description: 'Publish a kind 10002 relay list for the active identity. Warns if a relay list already exists.',
19
+ inputSchema: {
20
+ relays: z.array(z.object({
21
+ url: relayUrl.describe('Relay WebSocket URL'),
22
+ mode: z.enum(['read', 'write']).optional().describe('Read, write, or both (default)'),
23
+ })).describe('Relay entries'),
24
+ confirm: z.boolean().default(false).describe('Set true to overwrite existing relay list'),
25
+ },
26
+ annotations: { readOnlyHint: false },
27
+ }, async ({ relays, confirm }) => {
28
+ const result = await handleRelaySet(deps.ctx, deps.pool, { relays, confirm });
29
+ return {
30
+ content: [{ type: 'text', text: JSON.stringify(result.warning
31
+ ? { warning: result.warning }
32
+ : { published: true, id: result.event.id }, null, 2) }],
33
+ };
34
+ });
35
+ server.registerTool('relay_add', {
36
+ description: 'Add a single relay to the active identity\'s relay set (in-memory only — does not publish kind 10002).',
37
+ inputSchema: {
38
+ url: relayUrl.describe('Relay WebSocket URL'),
39
+ mode: z.enum(['read', 'write']).optional().describe('Read, write, or both (default)'),
40
+ },
41
+ annotations: { readOnlyHint: false },
42
+ }, async ({ url, mode }) => {
43
+ const result = handleRelayAdd(deps.ctx, deps.pool, { url, mode });
44
+ return {
45
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
46
+ };
47
+ });
48
+ server.registerTool('relay_info', {
49
+ description: 'Fetch the NIP-11 relay information document for a relay URL.',
50
+ inputSchema: {
51
+ url: relayUrl.describe('Relay WebSocket URL (wss://...)'),
52
+ },
53
+ annotations: { readOnlyHint: true },
54
+ }, async ({ url }) => {
55
+ const info = await handleRelayInfo(url);
56
+ return {
57
+ content: [{ type: 'text', text: JSON.stringify(info, null, 2) }],
58
+ };
59
+ });
60
+ }
@@ -0,0 +1,42 @@
1
+ import type { Event as NostrEvent, Filter } from 'nostr-tools';
2
+ import type { PublishResult, RelaySet } from './types.js';
3
+ /** Minimal pool interface for dependency injection (testability) */
4
+ export interface PoolLike {
5
+ publish(relays: string[], event: NostrEvent): Promise<string>[];
6
+ querySync(relays: string[], filter: Filter): Promise<NostrEvent[]>;
7
+ destroy(): void;
8
+ }
9
+ export interface RelayPoolConfig {
10
+ torProxy?: string;
11
+ allowClearnet: boolean;
12
+ defaultRelays: string[];
13
+ }
14
+ export declare class RelayPool {
15
+ private pool;
16
+ private poolReady;
17
+ private relaySets;
18
+ private writeQueue;
19
+ private defaults;
20
+ private torProxy?;
21
+ private allowClearnet;
22
+ constructor(config: RelayPoolConfig, injectedPool?: PoolLike);
23
+ /** Store relay set for an identity and flush any queued writes */
24
+ reconfigure(npub: string, relays: RelaySet): void;
25
+ /** Get relay set for an identity, falling back to defaults */
26
+ getRelays(npub: string): RelaySet;
27
+ /** Publish event to write relays for the given identity */
28
+ publish(npub: string, event: NostrEvent): Promise<PublishResult>;
29
+ /** One-shot query from read relays for the given identity */
30
+ query(npub: string, filter: Filter): Promise<NostrEvent[]>;
31
+ /** Queue an event for publishing once the identity's relay list is known */
32
+ queueWrite(npub: string, event: NostrEvent): void;
33
+ /** Number of queued events for an identity */
34
+ queueSize(npub: string): number;
35
+ /** Flush write queue for an identity, publishing all queued events */
36
+ flushQueue(npub: string): Promise<void>;
37
+ /** Find relay URLs shared between two identities */
38
+ checkSharedRelays(npubA: string, npubB: string): string[];
39
+ /** Close all connections */
40
+ close(): void;
41
+ private isOnion;
42
+ }
@@ -0,0 +1,150 @@
1
+ /** Initialise WebSocket and create a real SimplePool — lazy-loaded to avoid side effects at import */
2
+ async function createRealPool(torProxy) {
3
+ const { useWebSocketImplementation, SimplePool } = await import('nostr-tools/pool');
4
+ const WS = (await import('ws')).default;
5
+ if (torProxy) {
6
+ // Route all WebSocket connections through the SOCKS5h proxy for Tor
7
+ // hostname is passed unresolved (socks5h) so DNS happens at the proxy
8
+ const { SocksProxyAgent } = await import('socks-proxy-agent');
9
+ const agent = new SocksProxyAgent(torProxy);
10
+ const ProxiedWebSocket = class extends WS {
11
+ constructor(url, protocols, options) {
12
+ super(url, protocols, { ...options, agent });
13
+ }
14
+ };
15
+ useWebSocketImplementation(ProxiedWebSocket);
16
+ }
17
+ else {
18
+ useWebSocketImplementation(WS);
19
+ }
20
+ return new SimplePool();
21
+ }
22
+ export class RelayPool {
23
+ pool;
24
+ poolReady;
25
+ relaySets = new Map();
26
+ writeQueue = new Map();
27
+ defaults;
28
+ torProxy;
29
+ allowClearnet;
30
+ constructor(config, injectedPool) {
31
+ this.torProxy = config.torProxy;
32
+ this.allowClearnet = config.allowClearnet;
33
+ // Validate Tor/clearnet policy on default relays
34
+ if (config.torProxy && !config.allowClearnet) {
35
+ const clearnet = config.defaultRelays.filter(r => !this.isOnion(r));
36
+ if (clearnet.length > 0) {
37
+ throw new Error(`Clearnet relays not allowed with Tor proxy (set allowClearnet to override): ${clearnet.join(', ')}`);
38
+ }
39
+ }
40
+ this.defaults = {
41
+ read: [...config.defaultRelays],
42
+ write: [...config.defaultRelays],
43
+ };
44
+ if (injectedPool) {
45
+ this.pool = injectedPool;
46
+ this.poolReady = Promise.resolve(injectedPool);
47
+ }
48
+ else {
49
+ this.poolReady = createRealPool(config.torProxy).then(p => { this.pool = p; return p; });
50
+ }
51
+ }
52
+ /** Store relay set for an identity and flush any queued writes */
53
+ reconfigure(npub, relays) {
54
+ // Enforce Tor policy on runtime relay additions
55
+ if (this.torProxy && !this.allowClearnet) {
56
+ const allUrls = [...relays.read, ...relays.write];
57
+ const clearnet = allUrls.filter(r => !this.isOnion(r));
58
+ if (clearnet.length > 0) {
59
+ throw new Error(`Clearnet relays not allowed with Tor proxy: ${clearnet.join(', ')}`);
60
+ }
61
+ }
62
+ this.relaySets.set(npub, relays);
63
+ void this.flushQueue(npub);
64
+ }
65
+ /** Get relay set for an identity, falling back to defaults */
66
+ getRelays(npub) {
67
+ return this.relaySets.get(npub) ?? this.defaults;
68
+ }
69
+ /** Publish event to write relays for the given identity */
70
+ async publish(npub, event) {
71
+ const pool = await this.poolReady;
72
+ const relays = this.getRelays(npub);
73
+ const writeRelays = relays.write;
74
+ if (writeRelays.length === 0) {
75
+ return { success: false, accepted: [], rejected: [], errors: ['no write relays configured'] };
76
+ }
77
+ const promises = pool.publish(writeRelays, event);
78
+ const accepted = [];
79
+ const rejected = [];
80
+ const errors = [];
81
+ const results = await Promise.allSettled(promises);
82
+ for (let i = 0; i < results.length; i++) {
83
+ const result = results[i];
84
+ const url = writeRelays[i] ?? `relay-${i}`;
85
+ if (result.status === 'fulfilled') {
86
+ accepted.push(url);
87
+ }
88
+ else {
89
+ rejected.push(url);
90
+ errors.push(`${url}: ${result.reason}`);
91
+ }
92
+ }
93
+ return {
94
+ success: accepted.length === writeRelays.length,
95
+ accepted,
96
+ rejected,
97
+ errors,
98
+ };
99
+ }
100
+ /** One-shot query from read relays for the given identity */
101
+ async query(npub, filter) {
102
+ const pool = await this.poolReady;
103
+ const relays = this.getRelays(npub);
104
+ return pool.querySync(relays.read, filter);
105
+ }
106
+ /** Queue an event for publishing once the identity's relay list is known */
107
+ queueWrite(npub, event) {
108
+ const queue = this.writeQueue.get(npub) ?? [];
109
+ if (queue.length >= 100) {
110
+ throw new Error(`Write queue full for ${npub} (max 100 events). Resolve relay list first.`);
111
+ }
112
+ queue.push(event);
113
+ this.writeQueue.set(npub, queue);
114
+ }
115
+ /** Number of queued events for an identity */
116
+ queueSize(npub) {
117
+ return this.writeQueue.get(npub)?.length ?? 0;
118
+ }
119
+ /** Flush write queue for an identity, publishing all queued events */
120
+ async flushQueue(npub) {
121
+ const queue = this.writeQueue.get(npub);
122
+ if (!queue || queue.length === 0)
123
+ return;
124
+ this.writeQueue.delete(npub);
125
+ for (const event of queue) {
126
+ await this.publish(npub, event);
127
+ }
128
+ }
129
+ /** Find relay URLs shared between two identities */
130
+ checkSharedRelays(npubA, npubB) {
131
+ const relaysA = this.getRelays(npubA);
132
+ const relaysB = this.getRelays(npubB);
133
+ const allA = new Set([...relaysA.read, ...relaysA.write]);
134
+ const allB = new Set([...relaysB.read, ...relaysB.write]);
135
+ return [...allA].filter(url => allB.has(url));
136
+ }
137
+ /** Close all connections */
138
+ close() {
139
+ this.pool?.destroy();
140
+ }
141
+ isOnion(url) {
142
+ try {
143
+ const parsed = new URL(url);
144
+ return parsed.hostname.endsWith('.onion');
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ }
150
+ }
@@ -0,0 +1,15 @@
1
+ import type { IdentityContext } from '../context.js';
2
+ import type { RelayPool } from '../relay-pool.js';
3
+ /** Configure a duress persona — derives it and pre-warms relay connections */
4
+ export declare function handleDuressConfigure(ctx: IdentityContext, pool: RelayPool, args: {
5
+ personaName?: string;
6
+ }): {
7
+ npub: string;
8
+ configured: boolean;
9
+ };
10
+ /** Activate duress persona — switches identity. Response is identical to identity_switch. */
11
+ export declare function handleDuressActivate(ctx: IdentityContext, args: {
12
+ personaName?: string;
13
+ }): {
14
+ npub: string;
15
+ };
@@ -0,0 +1,12 @@
1
+ /** Configure a duress persona — derives it and pre-warms relay connections */
2
+ export function handleDuressConfigure(ctx, pool, args) {
3
+ const name = args.personaName ?? 'anonymous';
4
+ const persona = ctx.derivePersona(name, 0);
5
+ return { npub: persona.npub, configured: true };
6
+ }
7
+ /** Activate duress persona — switches identity. Response is identical to identity_switch. */
8
+ export function handleDuressActivate(ctx, args) {
9
+ const name = args.personaName ?? 'anonymous';
10
+ ctx.switch(name);
11
+ return { npub: ctx.activeNpub };
12
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { ToolDeps } from '../identity/tools.js';
3
+ export declare function registerSafetyTools(server: McpServer, deps: ToolDeps): void;
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ import { handleDuressConfigure, handleDuressActivate } from './handlers.js';
3
+ export function registerSafetyTools(server, deps) {
4
+ server.registerTool('safety_configure', {
5
+ description: 'Configure an alternative identity persona and pre-warm relay connections.',
6
+ inputSchema: {
7
+ personaName: z.string().default('anonymous').describe('Persona name for the alternative identity'),
8
+ },
9
+ annotations: { readOnlyHint: false },
10
+ }, async ({ personaName }) => {
11
+ const result = handleDuressConfigure(deps.ctx, deps.pool, { personaName });
12
+ return {
13
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
14
+ };
15
+ });
16
+ // Description intentionally does NOT mention duress — appears as normal identity switch
17
+ server.registerTool('safety_activate', {
18
+ description: 'Switch to an alternative identity configuration.',
19
+ inputSchema: {
20
+ personaName: z.string().default('anonymous').describe('Persona name to switch to'),
21
+ },
22
+ annotations: { readOnlyHint: false },
23
+ }, async ({ personaName }) => {
24
+ const result = handleDuressActivate(deps.ctx, { personaName });
25
+ return {
26
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
27
+ };
28
+ });
29
+ }