diff --git a/.env.example b/.env.example index a488180..1d15813 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,7 @@ AI_MODELS='[{"id":"gemini-flash","name":"Gemini Flash","provider":"google","mode # Set the RSS worker limits. RSS_POLL_SECONDS=900 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 diff --git a/apps/web/package.json b/apps/web/package.json index 06055b7..530588b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,6 +26,7 @@ "@hugeicons/core-free-icons": "^4.3.5", "@hugeicons/react": "^1.1.10", "@mozilla/readability": "^0.6.0", + "@multiformats/dns": "^1.0.15", "@tailwindcss/vite": "^4", "@tanstack/react-router": "latest", "@tanstack/react-start": "latest", diff --git a/apps/web/src/server/content.test.ts b/apps/web/src/server/content.test.ts index e0cf7ae..62e6875 100644 --- a/apps/web/src/server/content.test.ts +++ b/apps/web/src/server/content.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" -import test from "node:test" -import { Effect } from "effect" +import dns from "node:dns/promises" +import test, { type TestContext } from "node:test" +import { ConfigProvider, Deferred, Effect, Fiber } from "effect" import { Agent, MockAgent } from "undici" import { canonicalUrl, @@ -10,6 +11,42 @@ import { validatePublicUrl, } 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 () => { for (const address of [ "0.0.0.0", @@ -21,6 +58,8 @@ test("article URLs reject local, private, mapped, and reserved addresses", async "100.64.0.1", "224.0.0.1", "192.0.2.1", + "198.18.0.109", + "198.19.255.254", "198.51.100.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) => { + 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) => { + 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() + let starts = 0 + let cancellations = 0 + context.mock.method( + globalThis, + "fetch", + (...[_input, options]: Parameters) => { + const signal = options?.signal + assert.ok(signal instanceof AbortSignal) + return new Promise((_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) => { + const type = new URL(String(input)).searchParams.get("type") + const signal = options?.signal + assert.ok(signal instanceof AbortSignal) + return new Promise((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) => { + 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) => + 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", () => { assert.equal( canonicalUrl( diff --git a/apps/web/src/server/content.ts b/apps/web/src/server/content.ts index 13fddb6..336cdab 100644 --- a/apps/web/src/server/content.ts +++ b/apps/web/src/server/content.ts @@ -1,7 +1,9 @@ -import { lookup } from "node:dns/promises" +import dns from "node:dns/promises" import { isIP } from "node:net" 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 { parseHTML } from "linkedom" import { Agent, fetch } from "undici" @@ -10,6 +12,7 @@ import { AppError } from "./errors" const MAX_BYTES = 2_000_000 const MAX_TEXT = 60_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]) export function isPublicAddress(address: string): boolean { @@ -51,6 +54,81 @@ export function canonicalUrl(value: string): string { 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")( function* (value: string) { const url = yield* Effect.try({ @@ -73,11 +151,11 @@ export const validatePublicUrl = Effect.fn("content.validatePublicUrl")( status: 400, }) } - const addresses = yield* Effect.tryPromise({ + let addresses = yield* Effect.tryPromise({ try: () => isIP(hostname) ? Promise.resolve([{ address: hostname, family: isIP(hostname) }]) - : lookup(hostname, { all: true, verbatim: true }), + : dns.lookup(hostname, { all: true, verbatim: true }), catch: (cause) => new AppError({ 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 ( !addresses.length || addresses.some(({ address }) => !isPublicAddress(address)) diff --git a/docs/configuration.md b/docs/configuration.md index 0b0dbc4..94c4b6f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -67,6 +67,20 @@ Provider keys remain on the server. Change these values to control RSS request volume. 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 Compose supplies these values to the application and worker. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c435a91..527cb83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 + '@multiformats/dns': + specifier: ^1.0.15 + version: 1.0.15 '@tailwindcss/vite': 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)) @@ -570,9 +573,16 @@ packages: cpu: [x64] os: [win32] + '@chainsafe/is-ip@2.1.0': + resolution: {integrity: sha512-KIjt+6IfysQ4GCv66xihEitBjvhU/bixbbbFxdJ1sqCp4uJ0wuZiYBPhksZoy4lfaF0k9cwNzY5upEW/VWdw3w==} + '@date-fns/tz@1.5.0': 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': resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} hasBin: true @@ -1437,6 +1447,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': 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': resolution: {integrity: sha512-8BviX/hK4Gd2eL1KdTwm0gfl4d3wdoErW0Jd00h6nomUAtk6IJk5vwQJc6kfWtJTzN5OEV/lgIiJKI8fiDGAlA==} engines: {node: '>=18'} @@ -1451,6 +1467,12 @@ packages: resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} 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': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -3360,6 +3382,9 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + hashlru@2.3.0: + resolution: {integrity: sha512-0cMsjjIC8I+D3M44pOQdsy0OHXGLVz6Z0beRuufhKa0KfaD2wGwAev6jILzXsd3/vpnNQJmWyZtIILqM1N+n5A==} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -3786,6 +3811,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + main-event@1.0.5: + resolution: {integrity: sha512-4l9z8r7Q446mhhVTwdHmfPksOYBwN2xa7ewEW2yxPVNQapIaAf57ORFLMB91SpMEMa4wXowect7fq3uMLjDnGA==} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -3974,6 +4002,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + multiformats@14.0.5: + resolution: {integrity: sha512-vbIm83F2yZ1pWJGS0yl0ysracIvv56LtbrIyiIQHoLdYDJOMoLfVFsXhh9DUH4SFdkdkFhucyWniihsNzVEjkQ==} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -4065,6 +4096,14 @@ packages: resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} 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: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -4163,6 +4202,9 @@ packages: resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} + progress-events@1.1.0: + resolution: {integrity: sha512-82DVc5tI36neVB3IjdXR11ztwGuoBc98em9ijzubeZKxI47OlV2Znq6mlPqE5xPDzO2Uw98GHiQSjj2favBCRQ==} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -4637,6 +4679,15 @@ packages: uhyphen@0.2.0: 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: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -4751,6 +4802,9 @@ packages: peerDependencies: 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: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -5285,8 +5339,15 @@ snapshots: '@biomejs/cli-win32-x64@2.5.15': optional: true + '@chainsafe/is-ip@2.1.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': dependencies: '@dotenvx/primitives': 0.8.0 @@ -5861,6 +5922,17 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@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)': dependencies: '@hono/node-server': 2.1.3(hono@4.13.12) @@ -5885,6 +5957,22 @@ snapshots: '@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)': dependencies: '@emnapi/core': 1.10.0 @@ -7572,6 +7660,8 @@ snapshots: has-symbols@1.1.0: {} + hashlru@2.3.0: {} + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -7892,6 +7982,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + main-event@1.0.5: {} + markdown-table@3.0.4: {} math-intrinsics@1.1.0: {} @@ -8281,6 +8373,8 @@ snapshots: ms@2.1.3: {} + multiformats@14.0.5: {} + mute-stream@2.0.0: {} nanoid@3.3.19: {} @@ -8383,6 +8477,13 @@ snapshots: dependencies: 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: {} parent-module@1.0.1: @@ -8461,6 +8562,8 @@ snapshots: dependencies: parse-ms: 4.0.0 + progress-events@1.1.0: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -9055,6 +9158,19 @@ snapshots: 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@7.30.0: {} @@ -9141,6 +9257,8 @@ snapshots: dependencies: react: 19.3.0 + utf8-codec@1.0.0: {} + util-deprecate@1.0.2: {} validate-npm-package-name@7.0.2: {} diff --git a/turbo.json b/turbo.json index 2721edf..6fcde15 100644 --- a/turbo.json +++ b/turbo.json @@ -34,6 +34,7 @@ "ANTHROPIC_API_KEY", "RSS_POLL_SECONDS", "RSS_CONCURRENCY", + "DNS_OVER_HTTPS_URL", "TEST_APP_ORIGIN", "TEST_BASE_URL", "TEST_ADMIN_PASSWORD",