Merge PR: fix(tor): wire Onion-Location interceptors into every OkHttp client

Merges nostr proposal e5865428 (v2) into main:
- fix(tor): wire Onion-Location interceptors into every OkHttp client
  (onionCache made non-nullable; OnionInterceptorWiringTest)
- refactor(napplet): route blob fetches through the shared OkHttpClientFactory
- fix(napplet): route brokered resource fetches by the applet's own Tor mode

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-06-26 17:41:28 -04:00
co-authored by Claude Opus 4.8
9 changed files with 418 additions and 59 deletions
@@ -320,7 +320,9 @@ class NappletBrokerService : Service() {
context = applicationContext,
ledger = ledger,
storage = storage,
torPort = { Amethyst.instance.torManager.activePortOrNull.value ?: -1 },
// Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes
// through Tor when asked + active, and falls back to clearnet otherwise.
httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) },
).broker()
cachedBroker = account to broker
return broker
@@ -56,6 +56,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import java.io.ByteArrayInputStream
import kotlin.time.Duration.Companion.seconds
@@ -70,10 +71,14 @@ class AccountNappletGateways(
private val context: Context,
private val ledger: NappletPermissionLedger,
private val storage: NappletStorage,
private val torPort: () -> Int,
private val httpClient: (useProxy: Boolean) -> OkHttpClient,
) {
private val consentSummary = NappletConsentSummary(context)
private val resourceFetcher = NappletResourceFetcher(account, torPort)
// Reuse the app-wide HTTP client so napplet blob fetches inherit the same Tor
// routing, Onion-Location discovery/rewriting, Blossom cache and pool as the
// rest of the app, instead of a private client that has to re-wire all of it.
private val resourceFetcher = NappletResourceFetcher(account, httpClient)
private val identityReader = AccountIdentityReader(account)
fun broker(): NappletBroker {
@@ -97,7 +102,7 @@ class AccountNappletGateways(
}
val wallet = NappletWalletGateway { invoice -> payInvoiceViaNwc(invoice) }
val resource = NappletResourceGateway { url -> resourceFetcher.fetch(url) }
val resource = NappletResourceGateway { url, coordinate -> resourceFetcher.fetch(url, coordinate) }
val identityReads = NappletIdentityGateway { method, argument -> identityReader.read(method, argument) }
val upload = NappletUploadGateway { bytes, contentType, filename -> uploadBlob(bytes, contentType, filename) }
val theme = NappletThemeGateway { currentThemeColors() }
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.napplet.gateways
import android.util.Base64
import com.vitorpamplona.amethyst.commons.napplet.NappletResource
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
@@ -40,8 +41,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.URLDecoder
/**
@@ -50,25 +49,34 @@ import java.net.URLDecoder
* `https:`, and `blossom:` URLs; blossom blobs are content-addressed and **sha256-verified** before
* returning, so a wrong server can never substitute the blob.
*
* Owns a Tor-routed [OkHttpClient], cached and rebuilt only when the active Tor port ([torPort])
* changes. Built per account (so it reads the right Blossom server list); consent is enforced by the
* Network goes through the app-wide [OkHttpClient] supplied by [httpClient] (the shared
* [com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager]). Reusing it — rather than
* standing up a private client — means napplet blob fetches inherit the same passive
* `Onion-Location` discovery + `.onion` rewriting, local Blossom cache redirect, connection pool
* and DNS as every other HTTP role. Tor-or-clearnet is chosen per request from the calling
* applet's [NappletNetworkRegistry] mode (locked napplets are pinned to Tor; nSites follow the
* user's per-site toggle), so a brokered fetch routes exactly like that applet's own page loads.
* Built per account (so it reads the right Blossom server list); consent is enforced by the
* broker before [fetch] ever runs.
*/
class NappletResourceFetcher(
private val account: Account,
private val torPort: () -> Int,
private val httpClient: (useProxy: Boolean) -> OkHttpClient,
) {
// Reused blob HTTP client, keyed by the active Tor port (see client()).
private var cachedHttp: Pair<Int, OkHttpClient>? = null
/** Fetches an https/data/blossom resource, or null if unsupported/unavailable. */
suspend fun fetch(url: String): NappletResource? =
/** Fetches an https/data/blossom resource for the applet at [coordinate], or null if unsupported/unavailable. */
suspend fun fetch(
url: String,
coordinate: String,
): NappletResource? =
withContext(Dispatchers.IO) {
// Route like the applet's own page: Tor when its network mode is Tor, clearnet otherwise.
NappletNetworkRegistry.awaitReady()
val client = httpClient(NappletNetworkRegistry.useTor(coordinate))
when {
url.startsWith("data:") -> decodeDataUrl(url)
url.startsWith("https://") -> {
runCatching {
client()
client
.newCall(
Request
.Builder()
@@ -84,7 +92,7 @@ class NappletResourceFetcher(
}
}.getOrNull()
}
url.startsWith("blossom:") -> fetchBlossom(url)
url.startsWith("blossom:") -> fetchBlossom(url, client)
url.startsWith("nostr:") -> resolveNostr(url)
else -> null
}
@@ -141,30 +149,16 @@ class NappletResourceFetcher(
.maxByOrNull { it.createdAt }
}
/**
* Tor-routed OkHttp client for host-side blob fetches (the applet has no direct network).
* Cached and reused for connection pooling; rebuilt only when the Tor proxy port changes.
*/
@Synchronized
private fun client(): OkHttpClient {
val port = torPort()
cachedHttp?.let { (cachedPort, client) -> if (cachedPort == port) return client }
val client =
if (port > 0) {
OkHttpClient.Builder().proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))).build()
} else {
OkHttpClient()
}
cachedHttp = port to client
return client
}
/**
* Fetches a `blossom:<sha256>` (or `blossom://<sha256>`) blob from the user's Blossom servers
* (kind:10063), verifying the sha256 before returning — content-addressed, so a wrong server
* can never substitute the blob. Returns null for a malformed hash or if no server serves it.
* (kind:10063) over [client], verifying the sha256 before returning — content-addressed, so a
* wrong server can never substitute the blob. Returns null for a malformed hash or if no server
* serves it.
*/
private fun fetchBlossom(url: String): NappletResource? {
private fun fetchBlossom(
url: String,
client: OkHttpClient,
): NappletResource? {
val hash =
url
.removePrefix("blossom://")
@@ -180,7 +174,6 @@ class NappletResourceFetcher(
.getBlossomServersList()
?.servers()
.orEmpty()
val client = client()
for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) {
val bytes =
runCatching {
@@ -40,7 +40,11 @@ class DualHttpClientManager(
scope: CoroutineScope,
dns: SurgeDns,
shouldBridgeBlossomCache: (() -> Boolean)? = null,
onionCache: OnionLocationCache? = null,
// Required (not nullable): every general-purpose HTTP client we mint must be
// wired into the OnionLocationCache so the app's `.onion`-routing behavior
// is uniform across image, upload, NIP-05, money, preview, and push roles.
// See [OkHttpClientFactory] kdoc.
onionCache: OnionLocationCache,
) : IHttpClientManager {
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache)
@@ -22,18 +22,31 @@ package com.vitorpamplona.amethyst.service.okhttp
import android.os.Build
internal fun isEmulator(): Boolean =
Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.lowercase().contains("emulator") ||
Build.MODEL.contains("google_sdk") ||
Build.MODEL.lowercase().contains("droid4x") ||
Build.MODEL.contains("Emulator") ||
Build.MODEL.contains("Android SDK built for x86") ||
Build.MANUFACTURER.contains("Genymotion") ||
(Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) ||
"google_sdk" == Build.PRODUCT ||
Build.HARDWARE.contains("goldfish") ||
Build.HARDWARE.contains("ranchu") ||
Build.HARDWARE.contains("vbox86") ||
Build.HARDWARE.contains("nox") ||
Build.HARDWARE.contains("cuttlefish")
// Kotlin types android.os.Build's static fields as non-null String, but the JVM stub
// used in unit tests (testOptions.unitTests.isReturnDefaultValues = true) leaves them
// null. A naive `.startsWith` would NPE there. Coalesce to empty so unit tests can
// instantiate the OkHttp factories — production behavior is unchanged (real Android
// always populates these).
internal fun isEmulator(): Boolean {
val fingerprint = Build.FINGERPRINT ?: ""
val model = Build.MODEL ?: ""
val manufacturer = Build.MANUFACTURER ?: ""
val brand = Build.BRAND ?: ""
val device = Build.DEVICE ?: ""
val product = Build.PRODUCT ?: ""
val hardware = Build.HARDWARE ?: ""
return fingerprint.startsWith("generic") ||
fingerprint.lowercase().contains("emulator") ||
model.contains("google_sdk") ||
model.lowercase().contains("droid4x") ||
model.contains("Emulator") ||
model.contains("Android SDK built for x86") ||
manufacturer.contains("Genymotion") ||
(brand.startsWith("generic") && device.startsWith("generic")) ||
"google_sdk" == product ||
hardware.contains("goldfish") ||
hardware.contains("ranchu") ||
hardware.contains("vbox86") ||
hardware.contains("nox") ||
hardware.contains("cuttlefish")
}
@@ -32,6 +32,22 @@ import java.net.Proxy
import java.time.Duration
import java.util.concurrent.TimeUnit
/**
* Builder for the general-purpose (non-relay) OkHttp client used by image
* downloads, NIP-96/Blossom uploads, NIP-05 lookups, LNURL/money, link
* previews, push registration, etc. Each [DualHttpClientManager] holds one
* of these and rebuilds clients on Tor proxy / mobile-data changes.
*
* [OnionLocationCache] is **required**: every OkHttp client minted by this
* factory installs [OnionLocationInterceptor] on the no-proxy and Tor-proxied
* variants so any HTTP/WebSocket response that carries an `Onion-Location`
* header (NIP-11 docs, image hosts, blossom servers, NIP-96 endpoints, LNURL
* providers, anything) populates the cache. Tor-proxied clients additionally
* install [OnionUrlRewriteInterceptor] so subsequent requests are rerouted
* to the cached `.onion` and avoid exit nodes entirely. Treating the cache as
* required (not nullable) prevents a future call site from silently disabling
* onion-routing for an entire HTTP role.
*/
class OkHttpClientFactory(
keyCache: EncryptionKeyCache,
val userAgent: String,
@@ -43,7 +59,7 @@ class OkHttpClientFactory(
* useful for tests or pre-configuration call sites.
*/
val shouldBridgeBlossomCache: (() -> Boolean)? = null,
private val onionCache: OnionLocationCache? = null,
private val onionCache: OnionLocationCache,
) {
// val logging = LoggingInterceptor()
val keyDecryptor = EncryptedBlobInterceptor(keyCache)
@@ -92,7 +108,11 @@ class OkHttpClientFactory(
}
// .addNetworkInterceptor(logging)
.addNetworkInterceptor(keyDecryptor)
.apply { onionCache?.let { addInterceptor(OnionLocationInterceptor(it)) } }
// Passively populates [onionCache] from any HTTP/WebSocket response
// that carries an `Onion-Location` header. Application-scoped so the
// cache key is always the original clearnet host (see kdoc on
// [OnionLocationInterceptor]).
.addInterceptor(OnionLocationInterceptor(onionCache))
.build()
private var lastProxy: Proxy? = null
@@ -109,7 +129,10 @@ class OkHttpClientFactory(
return rootClient
.newBuilder()
.proxy(proxy)
.apply { if (proxy != null) onionCache?.let { addInterceptor(OnionUrlRewriteInterceptor(it)) } }
// Only the Tor-routed variant rewrites outbound URLs to known
// `.onion`s — clearnet clients must never try to resolve `.onion`
// (DNS would fail, and we don't want fingerprintable lookups).
.apply { if (proxy != null) addInterceptor(OnionUrlRewriteInterceptor(onionCache)) }
.connectTimeout(Duration.ofSeconds(seconds.toLong()))
.readTimeout(Duration.ofSeconds(seconds.toLong() * 3))
.writeTimeout(Duration.ofSeconds(seconds.toLong() * 3))
@@ -0,0 +1,312 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.okhttp
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.lang.reflect.Proxy
/**
* Pins the Onion-Location interceptor behavior the entire app's onion-routing
* story relies on.
*
* Onion-Location is a free passive discovery mechanism: an `.onion`-equipped server
* advertises its onion address via an HTTP response header, and the app caches the
* mapping for later Tor-routed connections. The mechanism only works if **every**
* OkHttp client built by the app installs the interceptors:
*
* - [OnionLocationInterceptor] on every client (no-proxy AND Tor-proxied) so we capture
* the header from whichever client happened to make the request.
* - [OnionUrlRewriteInterceptor] on Tor-proxied clients only (clearnet clients must never
* try to resolve `.onion`).
*
* Wiring is enforced by [OkHttpClientFactory] and [OkHttpClientFactoryForRelays] taking
* the cache as a **non-nullable** constructor parameter and unconditionally adding the
* interceptors in their builder chain. Factory-instantiation in unit tests is blocked by
* OkHttp 5's Android platform shim (it calls `android.util.Log.isLoggable`, which only
* exists on a real device), so the wiring is pinned at the type level rather than via
* reflective inspection of a built client. This suite covers the interceptors themselves
* and the cache they share so any regression in their behavior — missing put, wrong
* rewrite scheme, stale TTL, etc. — fails fast.
*
* The chain is a [Proxy] that only implements [Interceptor.Chain.request] and
* [Interceptor.Chain.proceed]; same pattern as [LocalBlossomCacheRedirectInterceptorTest].
*/
class OnionInterceptorWiringTest {
// --- OnionLocationInterceptor: passively captures the header ---
@Test
fun locationInterceptor_recordsHeaderUnderClearnetHost() {
val cache = OnionLocationCache()
val interceptor = OnionLocationInterceptor(cache)
runWithResponse(
interceptor = interceptor,
url = "https://example.com/some/path",
responseHeaders = mapOf("Onion-Location" to "http://abcdef.onion/"),
)
assertEquals(
"Cache key must be the original clearnet host, not the .onion — otherwise " +
"the rewriter on the next request looks up a key that was never written.",
"http://abcdef.onion/",
cache.get("example.com"),
)
}
@Test
fun locationInterceptor_websocketUpgrade_alsoRecords() {
// The Onion-Location header is captured from any response — including the
// WebSocket 101 Switching Protocols upgrade handshake on a Nostr relay
// connection. This is the primary discovery path for Tor-routed relays.
// (OkHttp normalizes ws/wss to http/https on Request.url, so even though
// the relay URL is `wss://`, the interceptor sees `https://`. The cache
// entry is keyed by that normalized host, which is what the rewriter
// later looks up on the next outbound `wss://` request.)
val cache = OnionLocationCache()
val interceptor = OnionLocationInterceptor(cache)
runWithResponse(
interceptor = interceptor,
url = "https://relay.example/",
responseCode = 101,
responseHeaders = mapOf("Onion-Location" to "http://relay.onion/"),
)
assertEquals("http://relay.onion/", cache.get("relay.example"))
}
@Test
fun locationInterceptor_noHeader_writesNothing() {
val cache = OnionLocationCache()
val interceptor = OnionLocationInterceptor(cache)
runWithResponse(
interceptor = interceptor,
url = "https://example.com/",
responseHeaders = emptyMap(),
)
assertNull(
"A response with no Onion-Location must not pollute the cache.",
cache.get("example.com"),
)
}
// --- OnionUrlRewriteInterceptor: rewrites outbound URLs to known .onions ---
@Test
fun rewriteInterceptor_unknownHost_passesThrough() {
val cache = OnionLocationCache()
val interceptor = OnionUrlRewriteInterceptor(cache)
val captured = captureRewrite(interceptor, "https://example.com/")
assertEquals(
"Unknown hosts must reach the original URL untouched.",
"https://example.com/",
captured,
)
}
@Test
fun rewriteInterceptor_https_to_httpsOnion_keepsHttps() {
val cache = OnionLocationCache().apply { put("example.com", "https://abc.onion/") }
val interceptor = OnionUrlRewriteInterceptor(cache)
val captured = captureRewrite(interceptor, "https://example.com/foo")
val url = captured.toHttpUrl()
assertEquals("abc.onion", url.host)
assertEquals("https", url.scheme)
assertEquals("/foo", url.encodedPath)
}
@Test
fun rewriteInterceptor_https_to_httpOnion_downgradesToHttp() {
// An onion service advertising http:// is not a real downgrade: the Tor
// hidden-service descriptor already provides authenticated end-to-end
// encryption. We keep the request in the http protocol family.
val cache = OnionLocationCache().apply { put("example.com", "http://abc.onion:8080/") }
val interceptor = OnionUrlRewriteInterceptor(cache)
val url = captureRewrite(interceptor, "https://example.com/").toHttpUrl()
assertEquals("http", url.scheme)
assertEquals(8080, url.port)
}
// Note: OkHttp normalizes `ws/wss` URLs to `http/https` on `Request.url`
// before any interceptor runs, so the `ws/wss` branches in
// OnionUrlRewriteInterceptor.intercept() are defensive code only —
// unreachable from a real OkHttp call site. Tests of them would have to
// construct an HttpUrl with a ws/wss scheme, which the OkHttp 5 API
// refuses to parse. The behavior we DO need to pin is that the http/https
// path keeps the protocol family across the onion swap, which the
// `rewriteInterceptor_https_to_*` cases above already cover.
@Test
fun rewriteInterceptor_unparseableOnionUrl_passesThrough() {
// Malformed cache entries must not break connectivity — fall back to original.
val cache = OnionLocationCache().apply { put("example.com", "not a url") }
val interceptor = OnionUrlRewriteInterceptor(cache)
val captured = captureRewrite(interceptor, "https://example.com/")
assertEquals("https://example.com/", captured)
}
// --- OnionLocationCache: TTL + shared identity ---
@Test
fun cache_putGet_roundTripsValue() {
val cache = OnionLocationCache()
cache.put("example.com", "http://abc.onion/")
assertEquals("http://abc.onion/", cache.get("example.com"))
}
@Test
fun cache_unknownHost_returnsNull() {
val cache = OnionLocationCache()
assertNull(cache.get("never-seen.example"))
}
@Test
fun cache_isSharedAcrossInterceptorPair() {
// Critical invariant: an entry the location interceptor writes must be
// visible to the rewriter on the next request. Both interceptors close
// over the SAME cache instance — nothing fancier than object identity.
val cache = OnionLocationCache()
val location = OnionLocationInterceptor(cache)
val rewriter = OnionUrlRewriteInterceptor(cache)
// 1. First response advertises an onion.
runWithResponse(
interceptor = location,
url = "https://example.com/",
responseHeaders = mapOf("Onion-Location" to "http://abc.onion/"),
)
// 2. A subsequent request to the same host hits the rewriter and goes to .onion.
val capturedUrl = captureRewrite(rewriter, "https://example.com/api/v1").toHttpUrl()
assertEquals("abc.onion", capturedUrl.host)
assertEquals(
"Path must be preserved across the rewrite.",
"/api/v1",
capturedUrl.encodedPath,
)
assertNotEquals(
"Host must have changed.",
"example.com",
capturedUrl.host,
)
}
// --- Compile-time pin: the interceptors must remain OkHttp [Interceptor]s ---
@Test
fun interceptorClasses_remainOkHttpInterceptors() {
val cache = OnionLocationCache()
// Assigning to an `Interceptor` reference is the compile-time pin: a future
// refactor that drops the interface from either class breaks this build,
// because addInterceptor() in both factories takes Interceptor.
@Suppress("UNUSED_VARIABLE")
val a: Interceptor = OnionLocationInterceptor(cache)
@Suppress("UNUSED_VARIABLE")
val b: Interceptor = OnionUrlRewriteInterceptor(cache)
}
// --- Test helpers ---
/**
* Runs [interceptor] against a request to [url], having `proceed` return a synthetic
* response with [responseCode] / [responseHeaders]. Returns the URL that
* `proceed` was ultimately called with (so rewrite tests can read it).
*/
private fun runWithResponse(
interceptor: Interceptor,
url: String,
responseCode: Int = 200,
responseHeaders: Map<String, String> = emptyMap(),
): String {
val captured = mutableListOf<String>()
val chain = fakeChain(url, captured, responseCode, responseHeaders)
interceptor.intercept(chain).close()
return captured.single()
}
/**
* Convenience for rewrite-interceptor tests: returns the URL that `proceed`
* was called with (i.e. the rewritten URL, or the original if no rewrite).
*/
private fun captureRewrite(
interceptor: Interceptor,
url: String,
): String = runWithResponse(interceptor, url)
/**
* A dynamic-proxy chain that implements only [Interceptor.Chain.request] and
* [Interceptor.Chain.proceed]. Used because OkHttp 5's `Chain` declares ~40
* members (client-config getters and `withX` reconfigurers) that these tests
* never exercise.
*/
private fun fakeChain(
url: String,
captured: MutableList<String>,
responseCode: Int,
responseHeaders: Map<String, String>,
): Interceptor.Chain {
val request = Request.Builder().url(url.toHttpUrl()).build()
return Proxy.newProxyInstance(
Interceptor.Chain::class.java.classLoader,
arrayOf(Interceptor.Chain::class.java),
) { _, method, args ->
when (method.name) {
"request" -> request
"proceed" -> {
val proceeded = args[0] as Request
captured.add(proceeded.url.toString())
val builder =
Response
.Builder()
.request(proceeded)
.protocol(Protocol.HTTP_1_1)
.code(responseCode)
.message(if (responseCode == 101) "Switching Protocols" else "OK")
.body("".toResponseBody(null))
responseHeaders.forEach { (k, v) -> builder.header(k, v) }
builder.build()
}
else -> throw UnsupportedOperationException(method.name)
}
} as Interceptor.Chain
}
}
@@ -274,7 +274,7 @@ class NappletBroker(
is NappletRequest.ResourceBytes -> {
val gateway = resource ?: return NappletResponse.Unsupported("resource.bytes")
val fetched = gateway.fetch(request.url) ?: return NappletResponse.Failed("Could not fetch the resource.")
val fetched = gateway.fetch(request.url, identity.coordinate) ?: return NappletResponse.Failed("Could not fetch the resource.")
NappletResponse.Bytes(fetched.bytes, fetched.contentType)
}
@@ -161,9 +161,16 @@ class NappletResource(
* (`resource.bytes`). The host fetches https/blossom/nostr/data URLs on the applet's behalf —
* the applet itself has no direct network (CSP `connect-src 'none'`). Returns `null` for an
* unsupported scheme or a failed fetch.
*
* [coordinate] is the calling applet's identity coordinate (`author:identifier`), so the host can
* route the fetch the same way the applet's own page loads — through Tor or the open web — per that
* applet's/site's network mode.
*/
fun interface NappletResourceGateway {
suspend fun fetch(url: String): NappletResource?
suspend fun fetch(
url: String,
coordinate: String,
): NappletResource?
}
/** A completed upload: where the blob lives plus NIP-94-ish metadata. */