@pi-archimedes/web 0.0.0-stage → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ import { safeFetch } from '../network/fetch.js';
2
+ import type { SearchProvider, SearchOptions, WebConfig, SearchResultItem } from './types.js';
3
+
4
+ export const BraveProvider: SearchProvider = {
5
+ id: 'brave',
6
+ name: 'Brave',
7
+ isAvailable: (config) => !!(config.braveApiKey || process.env.BRAVE_API_KEY),
8
+ search: async (query, options, config) => {
9
+ const apiKey = config.braveApiKey ?? process.env.BRAVE_API_KEY;
10
+ if (!apiKey) throw new Error('Brave API Key not configured');
11
+
12
+ const response = await safeFetch(
13
+ `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${options.numResults ?? 5}`,
14
+ {
15
+ headers: {
16
+ 'X-Subscription-Token': apiKey,
17
+ 'Accept': 'application/json'
18
+ }
19
+ }
20
+ );
21
+
22
+ if (!response.ok) {
23
+ throw new Error(`[brave] error: ${response.status} ${response.statusText}`);
24
+ }
25
+
26
+ const data = await response.json();
27
+ return (data.web?.results ?? []).map((r: any) => ({
28
+ url: r.url,
29
+ title: r.title,
30
+ snippet: r.description
31
+ }));
32
+ }
33
+ };
@@ -0,0 +1,16 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { DuckDuckGoProvider } from './duckduckgo.js';
3
+ import * as fetchModule from '../network/fetch.js';
4
+
5
+ describe('DuckDuckGoProvider', () => {
6
+ it('should fetch and parse', async () => {
7
+ vi.spyOn(fetchModule, 'safeFetch').mockResolvedValue({
8
+ ok: true,
9
+ text: async () => '<div class="result"><a class="result__a" href="https://example.com">Example</a><div class="result__snippet">Snippet</div></div>',
10
+ } as any);
11
+
12
+ const results = await DuckDuckGoProvider.search('test', { numResults: 1 }, {} as any);
13
+ expect(results).toHaveLength(1);
14
+ expect(results[0]?.url).toBe('https://example.com');
15
+ });
16
+ });
@@ -0,0 +1,62 @@
1
+ import { parseHTML } from 'linkedom';
2
+ import { safeFetch } from '../network/fetch.js';
3
+ import type { SearchProvider, SearchResultItem } from './types.js';
4
+
5
+ export const parseDuckDuckGoHTML = (html: string): SearchResultItem[] => {
6
+ const { document } = parseHTML(html);
7
+ const results: SearchResultItem[] = [];
8
+ const elements = document.querySelectorAll('.result');
9
+
10
+ for (const el of elements as any) {
11
+ const a = el.querySelector('a.result__a') as HTMLAnchorElement | null;
12
+ const snippetEl = el.querySelector('.result__snippet') ?? el.querySelector('.result__body');
13
+
14
+ if (a && snippetEl) {
15
+ let url = a.getAttribute('href') ?? '';
16
+
17
+ const urlParams = new URLSearchParams(url.split('?')[1]);
18
+ const uddg = urlParams.get('uddg');
19
+ if (uddg) {
20
+ url = uddg;
21
+ }
22
+
23
+ if (url.startsWith('//')) {
24
+ url = 'https:' + url;
25
+ }
26
+
27
+ try {
28
+ new URL(url);
29
+ } catch {
30
+ continue;
31
+ }
32
+
33
+ results.push({
34
+ url,
35
+ title: a.textContent?.trim() ?? '',
36
+ snippet: snippetEl.textContent?.trim() ?? ''
37
+ });
38
+ }
39
+ }
40
+ return results;
41
+ };
42
+
43
+ export const DuckDuckGoProvider: SearchProvider = {
44
+ id: 'duckduckgo',
45
+ name: 'DuckDuckGo',
46
+ isAvailable: () => true,
47
+ search: async (query, options, _config) => {
48
+ const response = await safeFetch('https://html.duckduckgo.com/html/?q=' + encodeURIComponent(query), {
49
+ headers: {
50
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
51
+ }
52
+ });
53
+
54
+ if (!response.ok) {
55
+ throw new Error(`[duckduckgo] error: ${response.status} ${response.statusText}`);
56
+ }
57
+
58
+ const html = await response.text();
59
+ const results = parseDuckDuckGoHTML(html);
60
+ return results.slice(0, options.numResults ?? 5);
61
+ }
62
+ };
@@ -0,0 +1,35 @@
1
+ import { safeFetch } from '../network/fetch.js';
2
+ import type { SearchProvider, SearchOptions, WebConfig, SearchResultItem } from './types.js';
3
+
4
+ export const OpenAIProvider: SearchProvider = {
5
+ id: 'openai',
6
+ name: 'OpenAI',
7
+ isAvailable: (config) => !!(config.openaiApiKey || process.env.OPENAI_API_KEY),
8
+ search: async (query, options, config) => {
9
+ const apiKey = config.openaiApiKey ?? process.env.OPENAI_API_KEY;
10
+ if (!apiKey) throw new Error('OpenAI API Key not configured');
11
+
12
+ const response = await safeFetch('https://api.openai.com/v1/chat/completions', {
13
+ method: 'POST',
14
+ headers: {
15
+ 'Authorization': `Bearer ${apiKey}`,
16
+ 'Content-Type': 'application/json'
17
+ },
18
+ body: JSON.stringify({
19
+ model: 'gpt-4o',
20
+ messages: [{ role: 'user', content: `Search for: ${query}` }]
21
+ })
22
+ });
23
+
24
+ if (!response.ok) throw new Error(`[openai] error: ${response.status} ${response.statusText}`);
25
+
26
+ // Simplification for now, assuming standard provider interface expects SearchResultItem[]
27
+ // Note: OpenAI isn't a search provider in the same sense, but here we fulfill the interface
28
+ const data = await response.json();
29
+ return [{
30
+ url: 'https://openai.com',
31
+ title: 'OpenAI Response',
32
+ snippet: data.choices[0]?.message.content ?? ''
33
+ }];
34
+ }
35
+ };
@@ -0,0 +1,49 @@
1
+ import { safeFetch } from '../network/fetch.js';
2
+ import type { SearchProvider, SearchOptions, WebConfig, SearchResultItem } from './types.js';
3
+
4
+ export const PerplexityProvider: SearchProvider = {
5
+ id: 'perplexity',
6
+ name: 'Perplexity',
7
+ isAvailable: (config) => !!(config.perplexityApiKey || process.env.PERPLEXITY_API_KEY),
8
+ search: async (query, options, config) => {
9
+ const apiKey = config.perplexityApiKey ?? process.env.PERPLEXITY_API_KEY;
10
+ if (!apiKey) throw new Error('Perplexity API Key not configured');
11
+
12
+ const response = await safeFetch('https://api.perplexity.ai/chat/completions', {
13
+ method: 'POST',
14
+ headers: {
15
+ 'Authorization': `Bearer ${apiKey}`,
16
+ 'Content-Type': 'application/json'
17
+ },
18
+ body: JSON.stringify({
19
+ model: 'llama-3.1-sonar-small-128k-online',
20
+ messages: [{ role: 'user', content: query }]
21
+ })
22
+ });
23
+
24
+ if (!response.ok) {
25
+ throw new Error(`[perplexity] error: ${response.status} ${response.statusText}`);
26
+ }
27
+
28
+ const data = await response.json();
29
+
30
+ const results: SearchResultItem[] = [];
31
+ if (data.citations) {
32
+ data.citations.forEach((url: string, index: number) => {
33
+ results.push({
34
+ url,
35
+ title: `Source ${index + 1}`,
36
+ snippet: ''
37
+ });
38
+ });
39
+ }
40
+
41
+ results.push({
42
+ url: 'https://perplexity.ai',
43
+ title: 'Perplexity Answer',
44
+ snippet: data.choices[0]?.message.content ?? ''
45
+ });
46
+
47
+ return results;
48
+ }
49
+ };
@@ -0,0 +1,15 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { resolveProvider } from './registry.js';
3
+ import type { WebConfig } from '../config.js';
4
+
5
+ describe('registry provider resolution', () => {
6
+ it('should resolve explicit provider', () => {
7
+ const config = {};
8
+ expect(resolveProvider('duckduckgo', config).id).toBe('duckduckgo');
9
+ });
10
+
11
+ it('should fallback to duckduckgo if no config', () => {
12
+ const config = {};
13
+ expect(resolveProvider(undefined, config).id).toBe('duckduckgo');
14
+ });
15
+ });
@@ -0,0 +1,75 @@
1
+ import type { WebConfig, SearchProvider, SearchOptions, SearchResultItem } from './types.js';
2
+ import { DuckDuckGoProvider } from './duckduckgo.js';
3
+ import { BraveProvider } from './brave.js';
4
+ import { TavilyProvider } from './tavily.js';
5
+ import { PerplexityProvider } from './perplexity.js';
6
+ import { SearXNGProvider } from './searxng.js';
7
+ import pLimit from 'p-limit';
8
+
9
+ const ALL_PROVIDERS: SearchProvider[] = [
10
+ BraveProvider,
11
+ TavilyProvider,
12
+ PerplexityProvider,
13
+ SearXNGProvider,
14
+ DuckDuckGoProvider
15
+ ];
16
+
17
+ export const resolveProvider = (requested: string | undefined, config: WebConfig): SearchProvider => {
18
+ if (requested) {
19
+ const provider = ALL_PROVIDERS.find(p => p.id === requested);
20
+ if (provider) return provider;
21
+ }
22
+
23
+ if (config.defaultProvider && config.defaultProvider !== 'auto') {
24
+ const provider = ALL_PROVIDERS.find(p => p.id === config.defaultProvider);
25
+ if (provider && provider.isAvailable(config)) return provider;
26
+ }
27
+
28
+ // Brave -> Tavily -> Perplexity -> SearXNG -> DuckDuckGo
29
+ for (const provider of ALL_PROVIDERS) {
30
+ if (provider.isAvailable(config)) return provider;
31
+ }
32
+
33
+ return DuckDuckGoProvider;
34
+ };
35
+
36
+ export const executeSearch = async (
37
+ queries: string[],
38
+ options: SearchOptions,
39
+ config: WebConfig
40
+ ): Promise<{ provider: string; results: SearchResultItem[] }> => {
41
+ if (queries.length === 0) {
42
+ throw new Error('Queries array is empty');
43
+ }
44
+
45
+ const provider = resolveProvider(options.provider, config);
46
+ const limit = pLimit(3);
47
+
48
+ const results = await Promise.all(queries.map(q => limit(async () => provider.search(q, options, config))));
49
+ let merged = results.flat();
50
+
51
+
52
+ if (options.domainFilter && options.domainFilter.length > 0) {
53
+ merged = merged.filter((r) => {
54
+ try {
55
+ const h = new URL(r.url).hostname;
56
+ return options.domainFilter!.some((d) => h === d || h.endsWith('.' + d));
57
+ } catch {
58
+ return false;
59
+ }
60
+ });
61
+ }
62
+
63
+ const unique = Array.from(new Map(merged.map(r => {
64
+ try {
65
+ const url = new URL(r.url);
66
+ url.hash = '';
67
+ const canonical = url.toString().replace(/\/$/, '');
68
+ return [canonical, { ...r, url: canonical }];
69
+ } catch {
70
+ return [r.url, r];
71
+ }
72
+ })).values());
73
+
74
+ return { provider: provider.id, results: unique };
75
+ };
@@ -0,0 +1,26 @@
1
+ import { safeFetch } from '../network/fetch.js';
2
+ import type { SearchProvider, SearchOptions, WebConfig, SearchResultItem } from './types.js';
3
+
4
+ export const SearXNGProvider: SearchProvider = {
5
+ id: 'searxng',
6
+ name: 'SearXNG',
7
+ isAvailable: (config) => !!config.searxngUrl,
8
+ search: async (query, options, config) => {
9
+ if (!config.searxngUrl) throw new Error('SearXNG URL not configured');
10
+
11
+ const response = await safeFetch(`${config.searxngUrl}/search?format=json&q=${encodeURIComponent(query)}`, undefined, {
12
+ allowPrivateOrigin: config.searxngUrl
13
+ });
14
+
15
+ if (!response.ok) {
16
+ throw new Error(`[searxng] error: ${response.status} ${response.statusText}`);
17
+ }
18
+
19
+ const data = await response.json();
20
+ return (data.results ?? []).slice(0, options.numResults ?? 5).map((r: any) => ({
21
+ url: r.url,
22
+ title: r.title,
23
+ snippet: r.content ?? r.engine
24
+ }));
25
+ }
26
+ };
@@ -0,0 +1,35 @@
1
+ import { safeFetch } from '../network/fetch.js';
2
+ import type { SearchProvider, SearchOptions, WebConfig, SearchResultItem } from './types.js';
3
+
4
+ export const TavilyProvider: SearchProvider = {
5
+ id: 'tavily',
6
+ name: 'Tavily',
7
+ isAvailable: (config) => !!(config.tavilyApiKey || process.env.TAVILY_API_KEY),
8
+ search: async (query, options, config) => {
9
+ const apiKey = config.tavilyApiKey ?? process.env.TAVILY_API_KEY;
10
+ if (!apiKey) throw new Error('Tavily API Key not configured');
11
+
12
+ const response = await safeFetch('https://api.tavily.com/search', {
13
+ method: 'POST',
14
+ headers: {
15
+ 'Content-Type': 'application/json',
16
+ },
17
+ body: JSON.stringify({
18
+ api_key: apiKey,
19
+ query,
20
+ max_results: options.numResults ?? 5,
21
+ }),
22
+ });
23
+
24
+ if (!response.ok) {
25
+ throw new Error(`[tavily] error: ${response.status} ${response.statusText}`);
26
+ }
27
+
28
+ const data = await response.json();
29
+ return (data.results ?? []).map((r: any) => ({
30
+ url: r.url,
31
+ title: r.title,
32
+ snippet: r.content
33
+ }));
34
+ }
35
+ };
@@ -0,0 +1,26 @@
1
+ import type { WebConfig } from "../config.js";
2
+
3
+ export type { WebConfig };
4
+
5
+ export interface SearchResultItem {
6
+ title: string;
7
+ url: string;
8
+ snippet: string;
9
+ publishedDate?: string;
10
+ }
11
+
12
+ export interface SearchOptions {
13
+ numResults?: number;
14
+ recencyFilter?: string;
15
+ domainFilter?: string[];
16
+ proxy?: string;
17
+ provider?: string;
18
+ signal?: AbortSignal | undefined;
19
+ }
20
+
21
+ export interface SearchProvider {
22
+ id: string;
23
+ name: string;
24
+ isAvailable(config: WebConfig): boolean | Promise<boolean>;
25
+ search(query: string, options: SearchOptions, config: WebConfig): Promise<SearchResultItem[]>;
26
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { Text } from '@earendil-works/pi-tui';
3
+ import { reuseText, sanitizeStatus, renderWebSearchCall } from './renderer.js';
4
+
5
+ describe('renderer', () => {
6
+ it('reuseText recycles existing Text component', () => {
7
+ const existing = new Text('existing', 0, 0);
8
+ const context = { lastComponent: existing };
9
+ expect(reuseText(context)).toBe(existing);
10
+ });
11
+
12
+ it('reuseText creates new Text if not exists', () => {
13
+ const result = reuseText({});
14
+ expect(result).toBeInstanceOf(Text);
15
+ expect(result.render(100)).toEqual([]);
16
+ });
17
+
18
+ it('sanitizeStatus strips control characters', () => {
19
+ expect(sanitizeStatus('hello\nworld')).toBe('hello world');
20
+ expect(sanitizeStatus('tab\tspace')).toBe('tab space');
21
+ expect(sanitizeStatus('control\x00char')).toBe('control char');
22
+ });
23
+
24
+ it('exception resilience: renderer does not throw on malformed input', () => {
25
+ const mockTheme = { fg: () => "" } as any;
26
+ const mockContext = { lastComponent: new Text("", 0, 0) };
27
+ expect(() => {
28
+ renderWebSearchCall(null, mockTheme, mockContext);
29
+ }).not.toThrow();
30
+ });
31
+ });
@@ -0,0 +1,155 @@
1
+ import { Text } from "@earendil-works/pi-tui";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import { renderToolHeader, renderStatusLabel } from "@pi-archimedes/core/tool-render";
4
+
5
+ export function reuseText(context?: unknown): Text {
6
+ return (context && typeof context === "object" && "lastComponent" in context && (context as { lastComponent?: unknown }).lastComponent instanceof Text
7
+ ? (context as { lastComponent: Text }).lastComponent
8
+ : new Text("", 0, 0));
9
+ }
10
+
11
+ export function sanitizeRemoteString(text: string): string {
12
+ // Strip escape sequences, but allow \n
13
+ return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').replace(/[\x00-\x09\x0B-\x0C\x0E-\x1F\x7F]/g, '');
14
+ }
15
+
16
+ export function sanitizeStatus(text: string): string {
17
+ return text.replace(/[\n\t\r\x00-\x1F\x7F]/g, ' ').trim();
18
+ }
19
+
20
+ export function sanitizeExpanded(text: string): string {
21
+ return sanitizeRemoteString(text);
22
+ }
23
+
24
+ export function renderWebSearchCall(args: unknown, theme: Theme, context?: unknown): Text {
25
+ const text = reuseText(context);
26
+ try {
27
+ const params = args as { query?: string; queries?: string[] };
28
+ const query = params.queries?.[0] ?? params.query ?? "unknown";
29
+ text.setText(renderToolHeader("web_search", query, theme));
30
+ return text;
31
+ } catch {
32
+ text.setText("web_search error");
33
+ return text;
34
+ }
35
+ }
36
+
37
+ export function renderWebSearchResult(result: unknown, options: { expanded?: boolean; isPartial?: boolean }, theme: Theme, context?: unknown): Text {
38
+ const text = reuseText(context);
39
+ if (options?.isPartial) {
40
+ text.setText(renderStatusLabel("running", "in progress...", theme));
41
+ return text;
42
+ }
43
+ try {
44
+ const details = (result as { details?: any })?.details;
45
+ if (!details) {
46
+ text.setText(renderStatusLabel("error", sanitizeStatus("No details"), theme));
47
+ return text;
48
+ }
49
+
50
+ if (details.error) {
51
+ text.setText(renderStatusLabel("error", sanitizeStatus(details.error), theme));
52
+ return text;
53
+ }
54
+
55
+ const label = `${details.resultCount ?? 0} results (${details.provider ?? "unknown"})`;
56
+ if (options?.expanded) {
57
+ let output = "";
58
+ for (const res of details.results || []) {
59
+ output += `${theme.fg("accent", res.title)}\n${theme.fg("dim", res.url)}\n${theme.fg("muted", res.snippet)}\n\n`;
60
+ }
61
+ text.setText(sanitizeExpanded(output.trim()));
62
+ } else {
63
+ text.setText(renderStatusLabel("success", sanitizeStatus(label), theme));
64
+ }
65
+ return text;
66
+ } catch (e) {
67
+ text.setText(renderStatusLabel("error", sanitizeStatus(String(e)), theme));
68
+ return text;
69
+ }
70
+ }
71
+
72
+ export function renderFetchContentCall(args: unknown, theme: Theme, context?: unknown): Text {
73
+ const text = reuseText(context);
74
+ try {
75
+ const url = (args as { url?: string })?.url ?? "unknown";
76
+ text.setText(renderToolHeader("fetch_content", url, theme));
77
+ return text;
78
+ } catch {
79
+ text.setText("fetch_content error");
80
+ return text;
81
+ }
82
+ }
83
+
84
+ export function renderFetchContentResult(result: unknown, options: { expanded?: boolean; isPartial?: boolean }, theme: Theme, context?: unknown): Text {
85
+ const text = reuseText(context);
86
+ if (options?.isPartial) {
87
+ text.setText(renderStatusLabel("running", "in progress...", theme));
88
+ return text;
89
+ }
90
+ try {
91
+ const details = (result as { details?: any })?.details;
92
+ if (!details) {
93
+ text.setText(renderStatusLabel("error", sanitizeStatus("No details"), theme));
94
+ return text;
95
+ }
96
+ if (details.error) {
97
+ text.setText(renderStatusLabel("error", sanitizeStatus(details.error), theme));
98
+ return text;
99
+ }
100
+
101
+ if (options?.expanded) {
102
+ text.setText(sanitizeExpanded(`${details.title ?? "Untitled"}\n${details.url}\nExtractor: ${details.extractor ?? "default"}\nWords: ${details.wordCount ?? 0}\n\n${details.snippet ?? ""}`));
103
+ } else {
104
+ const label = `${details.status ?? 200} | ${details.title ?? "Untitled"} (${details.wordCount ?? 0} words)`;
105
+ text.setText(renderStatusLabel("success", sanitizeStatus(label), theme));
106
+ }
107
+ return text;
108
+ } catch (e) {
109
+ text.setText(renderStatusLabel("error", sanitizeStatus(String(e)), theme));
110
+ return text;
111
+ }
112
+ }
113
+
114
+ export function renderGetSearchContentCall(args: unknown, theme: Theme, context?: unknown): Text {
115
+ const text = reuseText(context);
116
+ try {
117
+ const id = (args as { responseId?: string })?.responseId ?? "unknown";
118
+ text.setText(renderToolHeader("get_search_content", id, theme));
119
+ return text;
120
+ } catch {
121
+ text.setText("get_search_content error");
122
+ return text;
123
+ }
124
+ }
125
+
126
+ export function renderGetSearchContentResult(result: unknown, options: { expanded?: boolean; isPartial?: boolean }, theme: Theme, context?: unknown): Text {
127
+ const text = reuseText(context);
128
+ if (options?.isPartial) {
129
+ text.setText(renderStatusLabel("running", "in progress...", theme));
130
+ return text;
131
+ }
132
+ try {
133
+ const details = (result as { details?: any })?.details;
134
+ if (!details) {
135
+ text.setText(renderStatusLabel("error", sanitizeStatus("No details"), theme));
136
+ return text;
137
+ }
138
+
139
+ if (options?.expanded) {
140
+ text.setText(sanitizeExpanded(details.matches ? details.matches.map((m: any) => m.passage).join("\n\n") : (details.content ?? "No content found")));
141
+ } else {
142
+ let label = "Content retrieved";
143
+ if (details.matches) {
144
+ label = `Found ${details.matches.length} passages`;
145
+ } else if (details.content) {
146
+ label = `${details.content.length} characters`;
147
+ }
148
+ text.setText(renderStatusLabel("success", sanitizeStatus(label), theme));
149
+ }
150
+ return text;
151
+ } catch (e) {
152
+ text.setText(renderStatusLabel("error", sanitizeStatus(String(e)), theme));
153
+ return text;
154
+ }
155
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { isPrivateIp, assertSafeUrl } from './ssrf.js';
3
+ import dns from 'dns';
4
+
5
+ describe('SSRF Guard', () => {
6
+ describe('isPrivateIp', () => {
7
+ it('detects loopback addresses', () => {
8
+ expect(isPrivateIp('127.0.0.1')).toBe(true);
9
+ expect(isPrivateIp('127.255.255.255')).toBe(true);
10
+ expect(isPrivateIp('::1')).toBe(true);
11
+ expect(isPrivateIp('::')).toBe(true);
12
+ });
13
+
14
+ it('detects private networks', () => {
15
+ expect(isPrivateIp('10.0.0.1')).toBe(true);
16
+ expect(isPrivateIp('172.16.0.1')).toBe(true);
17
+ expect(isPrivateIp('172.31.255.255')).toBe(true);
18
+ expect(isPrivateIp('192.168.1.1')).toBe(true);
19
+ });
20
+
21
+ it('detects link-local / metadata addresses', () => {
22
+ expect(isPrivateIp('169.254.169.254')).toBe(true);
23
+ expect(isPrivateIp('fe80::1')).toBe(true);
24
+ });
25
+
26
+ it('detects IPv6 ULA', () => {
27
+ expect(isPrivateIp('fc00::1')).toBe(true);
28
+ expect(isPrivateIp('fd00::1')).toBe(true);
29
+ });
30
+
31
+ it('detects IPv4-mapped IPv6 special cases', () => {
32
+ expect(isPrivateIp('::ffff:7f00:1')).toBe(true);
33
+ expect(isPrivateIp('[::ffff:127.0.0.1]')).toBe(true);
34
+ expect(isPrivateIp('::ffff:a9fe:a9fe')).toBe(true);
35
+ });
36
+
37
+ it('detects bracketed IPv6', () => {
38
+ expect(isPrivateIp('[::1]')).toBe(true);
39
+ });
40
+
41
+ it('detects CGNAT', () => {
42
+ expect(isPrivateIp('100.64.0.1')).toBe(true);
43
+ });
44
+
45
+ // SSRF testing:
46
+ it('allows public IPs', () => {
47
+ expect(isPrivateIp('8.8.8.8')).toBe(false);
48
+ });
49
+
50
+ it('allows public IPv6', () => {
51
+ expect(isPrivateIp('2606:4700:4700::1111')).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe('assertSafeUrl', () => {
56
+ beforeEach(() => {
57
+ vi.spyOn(dns.promises, 'lookup').mockImplementation(async () => { return { address: '93.184.216.34', family: 4 } as dns.LookupAddress; });
58
+ });
59
+
60
+ it('throws for localhost', async () => {
61
+ await expect(assertSafeUrl('http://localhost')).rejects.toThrow();
62
+ await expect(assertSafeUrl('http://127.0.0.1')).rejects.toThrow();
63
+ });
64
+
65
+ it('throws for private network IPs via DNS', async () => {
66
+ vi.spyOn(dns.promises, 'lookup').mockImplementation(async () => { return { address: '10.0.0.1', family: 4 } as dns.LookupAddress; });
67
+ await expect(assertSafeUrl('http://some-private-host')).rejects.toThrow();
68
+ });
69
+
70
+ it('allows public URLs', async () => {
71
+ await expect(assertSafeUrl('http://example.com')).resolves.not.toThrow();
72
+ });
73
+ });
74
+ });