content,dns: resolve proxy fake IP addresses safely

Use DNS over HTTPS only when a host has only proxy fake IP addresses.
Keep private address checks and pinned article connections.
Test DNS failures, redirects, and interruption.
This commit is contained in:
EFLKumo committed 2026-10-11 16:21:39 +08:00
1 parent ce87e56053
commit 3751fd00d3
7 files changed
+526 -6

No files matched your search

+4
View File
@@ -24,3 +24,7 @@ AI_MODELS='[{"id":"gemini-flash","name":"Gemini Flash","provider":"google","mode
# Set the RSS worker limits. # Set the RSS worker limits.
RSS_POLL_SECONDS=900 RSS_POLL_SECONDS=900
RSS_CONCURRENCY=2 RSS_CONCURRENCY=2
# Set the DNS over HTTPS fallback for proxy fake IP addresses.
# Set this value to off to disable the fallback.
DNS_OVER_HTTPS_URL=https://cloudflare-dns.com/dns-query
+1
View File
@@ -26,6 +26,7 @@
"@hugeicons/core-free-icons": "^4.3.5", "@hugeicons/core-free-icons": "^4.3.5",
"@hugeicons/react": "^1.1.10", "@hugeicons/react": "^1.1.10",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"@multiformats/dns": "^1.0.15",
"@tailwindcss/vite": "^4", "@tailwindcss/vite": "^4",
"@tanstack/react-router": "latest", "@tanstack/react-router": "latest",
"@tanstack/react-start": "latest", "@tanstack/react-start": "latest",
+295 -2
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict" import assert from "node:assert/strict"
import test from "node:test" import dns from "node:dns/promises"
import { Effect } from "effect" import test, { type TestContext } from "node:test"
import { ConfigProvider, Deferred, Effect, Fiber } from "effect"
import { Agent, MockAgent } from "undici" import { Agent, MockAgent } from "undici"
import { import {
canonicalUrl, canonicalUrl,
@@ -10,6 +11,42 @@ import {
validatePublicUrl, validatePublicUrl,
} from "./content" } from "./content"
const testUrl = "https://www.eflx.top/blog/ui-composition-first-principle"
const dnsEndpoint = "https://cloudflare-dns.com/dns-query"
function checkUrl(value = testUrl, endpoint = dnsEndpoint) {
return validatePublicUrl(value).pipe(
Effect.provideService(
ConfigProvider.ConfigProvider,
ConfigProvider.fromUnknown({ DNS_OVER_HTTPS_URL: endpoint }),
),
)
}
function fakeDns(context: TestContext) {
return context.mock.method(dns, "lookup", async () => [
{ address: "198.18.0.109", family: 4 },
{ address: "198.19.255.254", family: 4 },
])
}
function dnsAnswer(type: "A" | "AAAA", addresses: string[] = []) {
return Response.json({
Status: 0,
TC: false,
Question: [{ name: "www.eflx.top", type: type === "A" ? 1 : 28 }],
Answer: [
{ name: "www.eflx.top", type: 5, TTL: 300, data: "cname.example.com" },
...addresses.map((address) => ({
name: "cname.example.com",
type: type === "A" ? 1 : 28,
TTL: 300,
data: address,
})),
],
})
}
test("article URLs reject local, private, mapped, and reserved addresses", async () => { test("article URLs reject local, private, mapped, and reserved addresses", async () => {
for (const address of [ for (const address of [
"0.0.0.0", "0.0.0.0",
@@ -21,6 +58,8 @@ test("article URLs reject local, private, mapped, and reserved addresses", async
"100.64.0.1", "100.64.0.1",
"224.0.0.1", "224.0.0.1",
"192.0.2.1", "192.0.2.1",
"198.18.0.109",
"198.19.255.254",
"198.51.100.1", "198.51.100.1",
"203.0.113.1", "203.0.113.1",
"::", "::",
@@ -68,6 +107,260 @@ test("article URLs accept public IPv4 and IPv6 addresses", async () => {
} }
}) })
test("public DNS answers retain the native route without an HTTPS lookup", async (context) => {
const addresses = [
{ address: "66.33.60.129", family: 4 },
{ address: "2606:4700:4700::1111", family: 6 },
]
context.mock.method(dns, "lookup", async () => addresses)
const remote = context.mock.method(globalThis, "fetch", async () => {
throw new Error("A public native answer must not use the fallback.")
})
const result = await Effect.runPromise(checkUrl(testUrl, "invalid URL"))
assert.deepEqual(result.addresses, addresses)
assert.equal(remote.mock.callCount(), 0)
})
test("fake DNS uses public HTTPS answers when one address family has no records", async (context) => {
fakeDns(context)
const questions: string[] = []
context.mock.method(
globalThis,
"fetch",
async (...[input, options]: Parameters<typeof globalThis.fetch>) => {
const url = new URL(String(input))
assert.equal(`${url.origin}${url.pathname}`, dnsEndpoint)
assert.equal(url.searchParams.get("name"), "www.eflx.top")
assert.ok(options?.signal instanceof AbortSignal)
assert.equal(
new Headers(options.headers).get("accept"),
"application/dns-json",
)
const type = url.searchParams.get("type")
assert.ok(type === "A" || type === "AAAA")
questions.push(type)
return dnsAnswer(
type,
type === "A" ? ["66.33.60.129", "66.33.60.130"] : [],
)
},
)
const result = await Effect.runPromise(checkUrl())
assert.equal(result.url.toString(), testUrl)
assert.deepEqual(questions.sort(), ["A", "AAAA"])
assert.deepEqual(result.addresses, [
{ address: "66.33.60.129", family: 4 },
{ address: "66.33.60.130", family: 4 },
])
})
test("fake DNS retains both public address families from the HTTPS resolver", async (context) => {
fakeDns(context)
context.mock.method(
globalThis,
"fetch",
async (...[input]: Parameters<typeof globalThis.fetch>) => {
const type = new URL(String(input)).searchParams.get("type")
return type === "A"
? dnsAnswer("A", ["66.33.60.129"])
: dnsAnswer("AAAA", ["2606:4700:4700::1111"])
},
)
const result = await Effect.runPromise(checkUrl())
assert.deepEqual(result.addresses, [
{ address: "66.33.60.129", family: 4 },
{ address: "2606:4700:4700::1111", family: 6 },
])
})
test("private hosts, IP literals, and mixed DNS answers cannot use the fallback", async (context) => {
let addresses = [{ address: "198.18.0.109", family: 4 }]
context.mock.method(dns, "lookup", async () => addresses)
const remote = context.mock.method(globalThis, "fetch", async () => {
throw new Error("An unsafe host must not use the fallback.")
})
for (const host of [
"198.18.0.109",
"198.19.255.254",
"[::ffff:198.18.0.109]",
"localhost",
"feed.local",
"feed.internal",
]) {
await assert.rejects(Effect.runPromise(checkUrl(`https://${host}/`)))
}
for (const values of [
["127.0.0.1"],
["10.1.2.3"],
["198.18.0.109", "10.1.2.3"],
["198.18.0.109", "66.33.60.129"],
["198.18.0.109", "198.51.100.1"],
["66.33.60.129", "127.0.0.1"],
]) {
addresses = values.map((address) => ({ address, family: 4 }))
await assert.rejects(Effect.runPromise(checkUrl()))
}
assert.equal(remote.mock.callCount(), 0)
})
test("native lookup failures cannot bypass the DNS policy", async (context) => {
context.mock.method(dns, "lookup", async () => {
throw Object.assign(new Error("No DNS entry"), { code: "ENOTFOUND" })
})
const remote = context.mock.method(globalThis, "fetch", async () => {
throw new Error("A lookup failure must not use the fallback.")
})
await assert.rejects(Effect.runPromise(checkUrl()), /host could not be found/)
assert.equal(remote.mock.callCount(), 0)
})
test("HTTPS resolution rejects failures, incomplete answers, and private addresses", async (context) => {
fakeDns(context)
const remote = context.mock.method(globalThis, "fetch", async () =>
dnsAnswer("A", ["66.33.60.129"]),
)
const invalidResponses = [
() => new Response("Unavailable", { status: 503 }),
() => Response.json({ Status: 3, TC: false, Answer: [] }),
() => Response.json({ Status: 0, TC: true, Answer: [] }),
() => dnsAnswer("A"),
() => dnsAnswer("A", ["66.33.60.129", "127.0.0.1"]),
() => dnsAnswer("A", ["198.18.0.109"]),
() => dnsAnswer("AAAA", ["::ffff:127.0.0.1"]),
() => dnsAnswer("A", ["invalid address"]),
() => new Response("invalid JSON", { status: 200 }),
]
for (const response of invalidResponses) {
remote.mock.mockImplementation(async () => response())
await assert.rejects(Effect.runPromise(checkUrl()))
}
remote.mock.mockImplementation(async (input) => {
const type = new URL(String(input)).searchParams.get("type")
return type === "A"
? dnsAnswer("A", ["66.33.60.129"])
: dnsAnswer("AAAA", ["::1"])
})
await assert.rejects(Effect.runPromise(checkUrl()))
remote.mock.mockImplementation(async () => {
throw new Error("The resolver connection failed.")
})
await assert.rejects(Effect.runPromise(checkUrl()))
})
test("HTTPS resolution can be disabled and requires a valid trusted endpoint", async (context) => {
fakeDns(context)
const remote = context.mock.method(globalThis, "fetch", async () => {
throw new Error("An invalid endpoint must not receive a query.")
})
for (const endpoint of [
"off",
"invalid URL",
"http://cloudflare-dns.com/dns-query",
"https://user:secret@cloudflare-dns.com/dns-query",
"https://cloudflare-dns.com/dns-query?name=other.example",
"https://cloudflare-dns.com/dns-query#fragment",
]) {
await assert.rejects(Effect.runPromise(checkUrl(testUrl, endpoint)))
}
assert.equal(remote.mock.callCount(), 0)
})
test("interrupting HTTPS resolution cancels both address queries", {
timeout: 3_000,
}, async (context) => {
fakeDns(context)
const started = Deferred.makeUnsafe<void>()
let starts = 0
let cancellations = 0
context.mock.method(
globalThis,
"fetch",
(...[_input, options]: Parameters<typeof globalThis.fetch>) => {
const signal = options?.signal
assert.ok(signal instanceof AbortSignal)
return new Promise<Response>((_resolve, reject) => {
signal.addEventListener(
"abort",
() => {
cancellations++
reject(signal.reason)
},
{ once: true },
)
if (++starts === 2) Effect.runSync(Deferred.succeed(started, undefined))
})
},
)
const fiber = Effect.runFork(checkUrl())
context.after(() => Effect.runPromise(Fiber.interrupt(fiber)))
await Effect.runPromise(Deferred.await(started))
await Effect.runPromise(Fiber.interrupt(fiber))
assert.equal(cancellations, 2)
})
test("an HTTPS resolver failure cancels the other address query", {
timeout: 3_000,
}, async (context) => {
fakeDns(context)
let starts = 0
let failQuery: (() => void) | undefined
let cancelled = false
context.mock.method(
globalThis,
"fetch",
(...[input, options]: Parameters<typeof globalThis.fetch>) => {
const type = new URL(String(input)).searchParams.get("type")
const signal = options?.signal
assert.ok(signal instanceof AbortSignal)
return new Promise<Response>((resolve, reject) => {
if (type === "A")
failQuery = () =>
resolve(new Response("Unavailable", { status: 503 }))
signal.addEventListener(
"abort",
() => {
if (type === "AAAA") cancelled = true
reject(signal.reason)
},
{ once: true },
)
if (++starts === 2) failQuery?.()
})
},
)
await assert.rejects(Effect.runPromise(checkUrl()))
assert.equal(cancelled, true)
})
test("a public HTTPS DNS answer cannot permit a redirect to a private host", async (context) => {
fakeDns(context)
const resolver = context.mock.method(
globalThis,
"fetch",
async (...[input]: Parameters<typeof globalThis.fetch>) => {
const type = new URL(String(input)).searchParams.get("type")
return type === "A" ? dnsAnswer("A", ["66.33.60.129"]) : dnsAnswer("AAAA")
},
)
const mock = new MockAgent()
mock.disableNetConnect()
context.mock.method(
Agent.prototype,
"dispatch",
(...[options, handler]: Parameters<Agent["dispatch"]>) =>
mock.get(String(options.origin)).dispatch(options, handler),
)
context.after(() => mock.close())
mock
.get("https://www.eflx.top")
.intercept({ path: "/blog/ui-composition-first-principle", method: "GET" })
.reply(302, "", {
headers: { location: "http://169.254.169.254/latest/meta-data/" },
})
await assert.rejects(Effect.runPromise(fetchDocument(testUrl)), /public host/)
assert.equal(resolver.mock.callCount(), 2)
})
test("URL normalization retains article parameters and removes tracking", () => { test("URL normalization retains article parameters and removes tracking", () => {
assert.equal( assert.equal(
canonicalUrl( canonicalUrl(
+93 -4
View File
@@ -1,7 +1,9 @@
import { lookup } from "node:dns/promises" import dns from "node:dns/promises"
import { isIP } from "node:net" import { isIP } from "node:net"
import { Readability } from "@mozilla/readability" import { Readability } from "@mozilla/readability"
import { Effect } from "effect" import { RecordType } from "@multiformats/dns"
import { dnsJsonOverHttps } from "@multiformats/dns/resolvers"
import { Config, Effect } from "effect"
import ipaddr from "ipaddr.js" import ipaddr from "ipaddr.js"
import { parseHTML } from "linkedom" import { parseHTML } from "linkedom"
import { Agent, fetch } from "undici" import { Agent, fetch } from "undici"
@@ -10,6 +12,7 @@ import { AppError } from "./errors"
const MAX_BYTES = 2_000_000 const MAX_BYTES = 2_000_000
const MAX_TEXT = 60_000 const MAX_TEXT = 60_000
const NETWORK_TIMEOUT = 20_000 const NETWORK_TIMEOUT = 20_000
const FAKE_IP_RANGE = ipaddr.IPv4.parseCIDR("198.18.0.0/15")
const redirects = new Set([301, 302, 303, 307, 308]) const redirects = new Set([301, 302, 303, 307, 308])
export function isPublicAddress(address: string): boolean { export function isPublicAddress(address: string): boolean {
@@ -51,6 +54,81 @@ export function canonicalUrl(value: string): string {
return url.toString() return url.toString()
} }
const resolveProxyHostname = Effect.fn("content.resolveProxyHostname")(
function* (hostname: string) {
const endpoint = yield* Config.String("DNS_OVER_HTTPS_URL").pipe(
Config.withDefault("https://cloudflare-dns.com/dns-query"),
)
if (endpoint === "off") return []
const url = yield* Effect.try({
try: () => {
const url = new URL(endpoint)
if (
url.protocol !== "https:" ||
url.username ||
url.password ||
url.search ||
url.hash
)
throw new Error(
"Use an HTTPS DNS endpoint without credentials or parameters.",
)
return url
},
catch: (cause) =>
new AppError({
message: "Set DNS_OVER_HTTPS_URL to a valid HTTPS DNS endpoint.",
status: 500,
cause,
}),
})
const resolve = dnsJsonOverHttps(url.toString())
const responses = yield* Effect.forEach(
[RecordType.A, RecordType.AAAA],
(type) =>
Effect.tryPromise({
try: (signal) => resolve(hostname, { types: type, signal }),
catch: (cause) =>
new AppError({
message: "The public DNS lookup failed. Try again.",
status: 502,
cause,
}),
}).pipe(
Effect.filterOrFail(
(response) => response.Status === 0 && !response.TC,
() =>
new AppError({
message: "The public DNS lookup failed. Try again.",
status: 502,
}),
),
),
{ concurrency: 2 },
).pipe(
Effect.timeout("10 seconds"),
Effect.mapError((cause) =>
cause instanceof AppError
? cause
: new AppError({
message: "The public DNS lookup timed out. Try again.",
status: 502,
cause,
}),
),
)
return responses.flatMap((response) =>
response.Answer.flatMap((answer) => {
// The resolver normalizes numeric DNS record types to their names.
const type = String(answer.type)
return ["A", "AAAA", "1", "28"].includes(type)
? [{ address: answer.data, family: isIP(answer.data) }]
: []
}),
)
},
)
export const validatePublicUrl = Effect.fn("content.validatePublicUrl")( export const validatePublicUrl = Effect.fn("content.validatePublicUrl")(
function* (value: string) { function* (value: string) {
const url = yield* Effect.try({ const url = yield* Effect.try({
@@ -73,11 +151,11 @@ export const validatePublicUrl = Effect.fn("content.validatePublicUrl")(
status: 400, status: 400,
}) })
} }
const addresses = yield* Effect.tryPromise({ let addresses = yield* Effect.tryPromise({
try: () => try: () =>
isIP(hostname) isIP(hostname)
? Promise.resolve([{ address: hostname, family: isIP(hostname) }]) ? Promise.resolve([{ address: hostname, family: isIP(hostname) }])
: lookup(hostname, { all: true, verbatim: true }), : dns.lookup(hostname, { all: true, verbatim: true }),
catch: (cause) => catch: (cause) =>
new AppError({ new AppError({
message: "The URL host could not be found.", message: "The URL host could not be found.",
@@ -96,6 +174,17 @@ export const validatePublicUrl = Effect.fn("content.validatePublicUrl")(
}), }),
), ),
) )
if (
!isIP(hostname) &&
addresses.length > 0 &&
addresses.every(
({ address }) =>
isIP(address) === 4 &&
ipaddr.IPv4.parse(address).match(FAKE_IP_RANGE),
)
) {
addresses = yield* resolveProxyHostname(hostname)
}
if ( if (
!addresses.length || !addresses.length ||
addresses.some(({ address }) => !isPublicAddress(address)) addresses.some(({ address }) => !isPublicAddress(address))
+14
View File
@@ -67,6 +67,20 @@ Provider keys remain on the server.
Change these values to control RSS request volume. Change these values to control RSS request volume.
The worker uses each user's stored preferences to select articles. The worker uses each user's stored preferences to select articles.
## DNS lookup
The application uses the host's DNS settings for normal lookups.
Some network proxies return addresses in `198.18.0.0/15`.
These are called fake IP addresses.
Article and RSS connections do not use these addresses.
For a host with only these addresses, Stellar uses a DNS over HTTPS (DoH) resolver.
The default resolver is `https://cloudflare-dns.com/dns-query`.
Set `DNS_OVER_HTTPS_URL` to use another HTTPS DNS JSON endpoint.
Set `DNS_OVER_HTTPS_URL=off` to disable this fallback.
Stellar checks IPv4 and IPv6 answers and rejects private addresses.
It uses only checked addresses for article and RSS connections.
## Direct server values ## Direct server values
Compose supplies these values to the application and worker. Compose supplies these values to the application and worker.
+118
View File
@@ -53,6 +53,9 @@ importers:
'@mozilla/readability': '@mozilla/readability':
specifier: ^0.6.0 specifier: ^0.6.0
version: 0.6.0 version: 0.6.0
'@multiformats/dns':
specifier: ^1.0.15
version: 1.0.15
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4 specifier: ^4
version: 4.3.0(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)) version: 4.3.0(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15))
@@ -570,9 +573,16 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@chainsafe/is-ip@2.1.0':
resolution: {integrity: sha512-KIjt+6IfysQ4GCv66xihEitBjvhU/bixbbbFxdJ1sqCp4uJ0wuZiYBPhksZoy4lfaF0k9cwNzY5upEW/VWdw3w==}
'@date-fns/tz@1.5.0': '@date-fns/tz@1.5.0':
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
'@dnsquery/dns-packet@6.1.1':
resolution: {integrity: sha512-WXTuFvL3G+74SchFAtz3FgIYVOe196ycvGsMgvSH/8Goptb1qpIQtIuM4SOK9G9lhMWYpHxnXyy544ZhluFOew==}
engines: {node: '>=6'}
'@dotenvx/dotenvx@1.75.1': '@dotenvx/dotenvx@1.75.1':
resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==}
hasBin: true hasBin: true
@@ -1437,6 +1447,12 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@leichtgewicht/ip-codec@2.0.5':
resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==}
'@libp2p/interface@3.3.0':
resolution: {integrity: sha512-SXahM/4IgpiFKTtocbXYSTA1wEUVjktmT1yBCzBbc2Vgsu1VBCg6SvsFmXo2Hbeyev3D8EU+y3uaCKDB43C/Vg==}
'@modelcontextprotocol/sdk@1.32.0': '@modelcontextprotocol/sdk@1.32.0':
resolution: {integrity: sha512-8BviX/hK4Gd2eL1KdTwm0gfl4d3wdoErW0Jd00h6nomUAtk6IJk5vwQJc6kfWtJTzN5OEV/lgIiJKI8fiDGAlA==} resolution: {integrity: sha512-8BviX/hK4Gd2eL1KdTwm0gfl4d3wdoErW0Jd00h6nomUAtk6IJk5vwQJc6kfWtJTzN5OEV/lgIiJKI8fiDGAlA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1451,6 +1467,12 @@ packages:
resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
'@multiformats/dns@1.0.15':
resolution: {integrity: sha512-W0zAMABtAn+3chgFcPGvllKND7M6GblMAAFcJQTy+iMmGiyFErZYPzAh2b50Y3SFf340iFV7+ckVgZkGfUyxzA==}
'@multiformats/multiaddr@13.0.3':
resolution: {integrity: sha512-mEqqJ4r3a/uuFMTpRkU316wGNIDQNhuVWpm+ebKTQeYsfv9jXbPONWM6VVnj3KGUrwfsX7GZOyp4TFqEA2SPCw==}
'@napi-rs/wasm-runtime@1.1.5': '@napi-rs/wasm-runtime@1.1.5':
resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==}
peerDependencies: peerDependencies:
@@ -3360,6 +3382,9 @@ packages:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
hashlru@2.3.0:
resolution: {integrity: sha512-0cMsjjIC8I+D3M44pOQdsy0OHXGLVz6Z0beRuufhKa0KfaD2wGwAev6jILzXsd3/vpnNQJmWyZtIILqM1N+n5A==}
hasown@2.0.4: hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3786,6 +3811,9 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
main-event@1.0.5:
resolution: {integrity: sha512-4l9z8r7Q446mhhVTwdHmfPksOYBwN2xa7ewEW2yxPVNQapIaAf57ORFLMB91SpMEMa4wXowect7fq3uMLjDnGA==}
markdown-table@3.0.4: markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
@@ -3974,6 +4002,9 @@ packages:
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
multiformats@14.0.5:
resolution: {integrity: sha512-vbIm83F2yZ1pWJGS0yl0ysracIvv56LtbrIyiIQHoLdYDJOMoLfVFsXhh9DUH4SFdkdkFhucyWniihsNzVEjkQ==}
mute-stream@2.0.0: mute-stream@2.0.0:
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
engines: {node: ^18.17.0 || >=20.5.0} engines: {node: ^18.17.0 || >=20.5.0}
@@ -4065,6 +4096,14 @@ packages:
resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
p-queue@9.3.3:
resolution: {integrity: sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==}
engines: {node: '>=20'}
p-timeout@7.0.2:
resolution: {integrity: sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==}
engines: {node: '>=20'}
p-try@2.2.0: p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -4163,6 +4202,9 @@ packages:
resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==}
engines: {node: '>=18'} engines: {node: '>=18'}
progress-events@1.1.0:
resolution: {integrity: sha512-82DVc5tI36neVB3IjdXR11ztwGuoBc98em9ijzubeZKxI47OlV2Znq6mlPqE5xPDzO2Uw98GHiQSjj2favBCRQ==}
prompts@2.4.2: prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -4637,6 +4679,15 @@ packages:
uhyphen@0.2.0: uhyphen@0.2.0:
resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==}
uint8-varint@3.0.0:
resolution: {integrity: sha512-S4DdpXBaLwKcFo7f0bWzWfHjbZ/i3QhM842qn+ZvHjxqFCfUcEB9SQNcmI69S+zMlcmIcKxsk9Iyw77S2Kxv6Q==}
uint8arraylist@3.0.2:
resolution: {integrity: sha512-LDVoq9BQaGJzGDUovEnoX6rpKCvnY/Jbtws4ikwnBzjRbq5qBAFpBZevUEbSmMM87aO0Sp+wOZy2ZXf5yODmXQ==}
uint8arrays@6.1.1:
resolution: {integrity: sha512-iz7JN0XCSZYA111lhFG2Ui9EhFvTNekqSRHw3lvMHq+dzwWy1OQftxFQREEh4rffU0oSoXdQHsk2TiHKVm4fsA==}
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -4751,6 +4802,9 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
utf8-codec@1.0.0:
resolution: {integrity: sha512-S/QSLezp3qvG4ld5PUfXiH7mCFxLKjSVZRFkB3DOjgwHuJPFDkInAXc/anf7BAbHt/D38ozDzL+QMZ6/7gsI6w==}
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -5285,8 +5339,15 @@ snapshots:
'@biomejs/cli-win32-x64@2.5.15': '@biomejs/cli-win32-x64@2.5.15':
optional: true optional: true
'@chainsafe/is-ip@2.1.0': {}
'@date-fns/tz@1.5.0': {} '@date-fns/tz@1.5.0': {}
'@dnsquery/dns-packet@6.1.1':
dependencies:
'@leichtgewicht/ip-codec': 2.0.5
utf8-codec: 1.0.0
'@dotenvx/dotenvx@1.75.1': '@dotenvx/dotenvx@1.75.1':
dependencies: dependencies:
'@dotenvx/primitives': 0.8.0 '@dotenvx/primitives': 0.8.0
@@ -5861,6 +5922,17 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@leichtgewicht/ip-codec@2.0.5': {}
'@libp2p/interface@3.3.0':
dependencies:
'@multiformats/dns': 1.0.15
'@multiformats/multiaddr': 13.0.3
main-event: 1.0.5
multiformats: 14.0.5
progress-events: 1.1.0
uint8arraylist: 3.0.2
'@modelcontextprotocol/sdk@1.32.0(supports-color@7.2.0)(zod@3.25.76)': '@modelcontextprotocol/sdk@1.32.0(supports-color@7.2.0)(zod@3.25.76)':
dependencies: dependencies:
'@hono/node-server': 2.1.3(hono@4.13.12) '@hono/node-server': 2.1.3(hono@4.13.12)
@@ -5885,6 +5957,22 @@ snapshots:
'@mozilla/readability@0.6.0': {} '@mozilla/readability@0.6.0': {}
'@multiformats/dns@1.0.15':
dependencies:
'@dnsquery/dns-packet': 6.1.1
'@libp2p/interface': 3.3.0
hashlru: 2.3.0
p-queue: 9.3.3
progress-events: 1.1.0
uint8arrays: 6.1.1
'@multiformats/multiaddr@13.0.3':
dependencies:
'@chainsafe/is-ip': 2.1.0
multiformats: 14.0.5
uint8-varint: 3.0.0
uint8arrays: 6.1.1
'@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies: dependencies:
'@emnapi/core': 1.10.0 '@emnapi/core': 1.10.0
@@ -7572,6 +7660,8 @@ snapshots:
has-symbols@1.1.0: {} has-symbols@1.1.0: {}
hashlru@2.3.0: {}
hasown@2.0.4: hasown@2.0.4:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
@@ -7892,6 +7982,8 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
main-event@1.0.5: {}
markdown-table@3.0.4: {} markdown-table@3.0.4: {}
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
@@ -8281,6 +8373,8 @@ snapshots:
ms@2.1.3: {} ms@2.1.3: {}
multiformats@14.0.5: {}
mute-stream@2.0.0: {} mute-stream@2.0.0: {}
nanoid@3.3.19: {} nanoid@3.3.19: {}
@@ -8383,6 +8477,13 @@ snapshots:
dependencies: dependencies:
p-limit: 2.3.0 p-limit: 2.3.0
p-queue@9.3.3:
dependencies:
eventemitter3: 5.0.4
p-timeout: 7.0.2
p-timeout@7.0.2: {}
p-try@2.2.0: {} p-try@2.2.0: {}
parent-module@1.0.1: parent-module@1.0.1:
@@ -8461,6 +8562,8 @@ snapshots:
dependencies: dependencies:
parse-ms: 4.0.0 parse-ms: 4.0.0
progress-events@1.1.0: {}
prompts@2.4.2: prompts@2.4.2:
dependencies: dependencies:
kleur: 3.0.3 kleur: 3.0.3
@@ -9055,6 +9158,19 @@ snapshots:
uhyphen@0.2.0: {} uhyphen@0.2.0: {}
uint8-varint@3.0.0:
dependencies:
uint8arraylist: 3.0.2
uint8arrays: 6.1.1
uint8arraylist@3.0.2:
dependencies:
uint8arrays: 6.1.1
uint8arrays@6.1.1:
dependencies:
multiformats: 14.0.5
undici-types@6.21.0: {} undici-types@6.21.0: {}
undici@7.30.0: {} undici@7.30.0: {}
@@ -9141,6 +9257,8 @@ snapshots:
dependencies: dependencies:
react: 19.3.0 react: 19.3.0
utf8-codec@1.0.0: {}
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
validate-npm-package-name@7.0.2: {} validate-npm-package-name@7.0.2: {}
+1
View File
@@ -34,6 +34,7 @@
"ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY",
"RSS_POLL_SECONDS", "RSS_POLL_SECONDS",
"RSS_CONCURRENCY", "RSS_CONCURRENCY",
"DNS_OVER_HTTPS_URL",
"TEST_APP_ORIGIN", "TEST_APP_ORIGIN",
"TEST_BASE_URL", "TEST_BASE_URL",
"TEST_ADMIN_PASSWORD", "TEST_ADMIN_PASSWORD",