refactor: replace napplet host websiteMode boolean with a HostProfile type

The "locked nApplet vs open nSite" distinction was a `websiteMode: Boolean`
threaded through an Intent extra and re-branched at eight independent sites
across the launcher, the content server, both host surfaces, and the chrome.
That is one coupled security posture (capabilities, CSP, NIP-07 injection,
off-origin policy, network UI) expressed as scattered, drift-prone flags — and
boolean-blind, since the "website" is actually the *more* capable mode.

Introduce `HostProfile` (NAPPLET | WEBSITE), resolved once in the trusted main
process and carried over the Intent/Messenger boundary as its name. Every
coupled consequence now reads from one place:
- `declaredCapabilities(requires)` — THE broker grant, minted into the token
- `appCsp` / `injectsNip07` / `allowsOffOrigin` / `exposesNetwork`

Pure mechanical mapping (every branch 1:1, no behavior change). The wire extra
`EXTRA_WEBSITE_MODE` boolean becomes `EXTRA_HOST_PROFILE` string — safe, as it
is in-app process-to-process IPC, never persisted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HrxLCMcQADUPnm8Sj9ZJ63
This commit is contained in:
Claude
2026-06-25 18:19:16 +00:00
parent 9a064984a5
commit 7d163b89c9
9 changed files with 119 additions and 48 deletions
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
@@ -117,7 +118,7 @@ object FavoriteAppLauncher {
aggregateHash = null,
title = event.title() ?: "nsite",
requires = emptyList(),
websiteMode = true,
profile = HostProfile.WEBSITE,
)
is NamedSiteEvent ->
NappletLauncher.launch(
@@ -129,7 +130,7 @@ object FavoriteAppLauncher {
aggregateHash = null,
title = event.title() ?: event.identifier(),
requires = emptyList(),
websiteMode = true,
profile = HostProfile.WEBSITE,
)
else -> {
Log.w("FavoriteAppLauncher", "Favorited app not resolvable yet: $coordinate")
@@ -160,7 +161,7 @@ object FavoriteAppLauncher {
event.declaredAggregateHash() ?: event.computeAggregateHash(),
event.title() ?: "Napplet",
event.requires(),
false,
HostProfile.NAPPLET,
)
is NamedNappletEvent ->
NappletLauncher.buildLaunchParams(
@@ -172,7 +173,7 @@ object FavoriteAppLauncher {
event.declaredAggregateHash() ?: event.computeAggregateHash(),
event.title() ?: event.identifier(),
event.requires(),
false,
HostProfile.NAPPLET,
)
is RootSiteEvent ->
NappletLauncher.buildLaunchParams(
@@ -184,7 +185,7 @@ object FavoriteAppLauncher {
null,
event.title() ?: "nsite",
emptyList(),
true,
HostProfile.WEBSITE,
)
is NamedSiteEvent ->
NappletLauncher.buildLaunchParams(
@@ -196,7 +197,7 @@ object FavoriteAppLauncher {
null,
event.title() ?: event.identifier(),
emptyList(),
true,
HostProfile.WEBSITE,
)
else -> null
}
@@ -25,11 +25,10 @@ import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletHostActivity
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -74,12 +73,12 @@ object NappletLauncher {
aggregateHash: HexKey?,
title: String,
requires: List<String>,
// nSites open in "website mode": a NIP-07 window.nostr provider + normal network. The broker
// then grants the IDENTITY + RELAY capabilities NIP-07 needs (consent-gated), regardless of the
// (empty) manifest `requires`. Napplets pass false and keep their declared-only, locked sandbox.
websiteMode: Boolean = false,
// nSites open as [HostProfile.WEBSITE]: a NIP-07 window.nostr provider + normal network. The
// broker then grants the IDENTITY + RELAY capabilities NIP-07 needs (consent-gated), regardless
// of the (empty) manifest `requires`. Napplets keep the default locked [HostProfile.NAPPLET].
profile: HostProfile = HostProfile.NAPPLET,
) {
val params = buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, websiteMode)
val params = buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile)
val intent =
Intent(context, NappletHostActivity::class.java).apply {
putExtras(params)
@@ -104,7 +103,7 @@ object NappletLauncher {
aggregateHash: HexKey?,
title: String,
requires: List<String>,
websiteMode: Boolean,
profile: HostProfile,
): Bundle {
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
@@ -120,18 +119,13 @@ object NappletLauncher {
// Mint the launch token in the (trusted) main process: the broker resolves the sandbox's
// requests back to THIS identity + declared set, regardless of anything the sandbox sends.
val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = aggregateHash)
val declared =
if (websiteMode) {
setOf(NappletCapability.IDENTITY, NappletCapability.RELAY)
} else {
resolveRequiredCapabilities(requires).capabilities.toSet()
}
val declared = profile.declaredCapabilities(requires)
val launchToken = NappletLaunchRegistry.register(identity, declared)
// Resolve the per-site network choice (Tor default; a site can be opted out to the open web).
// Locked napplets always keep Tor for their blob fetches — only nSites expose the toggle.
NappletNetworkRegistry.init(context.applicationContext)
val useTor = if (websiteMode) NappletNetworkRegistry.useTor(identity.coordinate) else true
val useTor = if (profile.exposesNetwork) NappletNetworkRegistry.useTor(identity.coordinate) else true
// Resolve capability labels here (the app has the resources) so the sandbox module needs none.
val capLabels = declared.map { context.getString(it.labelRes()) }
@@ -159,7 +153,7 @@ object NappletLauncher {
putStringArrayList(NappletHostContract.EXTRA_CAP_LABELS, ArrayList(capLabels))
putString(NappletHostContract.EXTRA_LAUNCH_TOKEN, launchToken)
putInt(NappletHostContract.EXTRA_PROXY_PORT, proxyPort)
putBoolean(NappletHostContract.EXTRA_WEBSITE_MODE, websiteMode)
putString(NappletHostContract.EXTRA_HOST_PROFILE, profile.name)
putBoolean(NappletHostContract.EXTRA_USE_TOR, useTor)
putString(NappletHostContract.EXTRA_THEME, theme)
}
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.StaticWebsiteCard
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletBlobPrefetcher
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -132,7 +133,7 @@ fun RenderRootSiteEvent(
aggregateHash = null,
title = event.title() ?: "nsite",
requires = emptyList(),
websiteMode = true,
profile = HostProfile.WEBSITE,
)
}
} else {
@@ -173,7 +174,7 @@ fun RenderNamedSiteEvent(
aggregateHash = null,
title = event.title() ?: event.identifier(),
requires = emptyList(),
websiteMode = true,
profile = HostProfile.WEBSITE,
)
}
} else {
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
@@ -122,7 +123,7 @@ private fun EmbeddedNostrAppTab(
val title = params.getString(NappletHostContract.EXTRA_TITLE).orEmpty()
val capLabels = params.getStringArrayList(NappletHostContract.EXTRA_CAP_LABELS).orEmpty()
val websiteMode = params.getBoolean(NappletHostContract.EXTRA_WEBSITE_MODE, false)
val profile = HostProfile.fromName(params.getString(NappletHostContract.EXTRA_HOST_PROFILE))
val useTor = params.getBoolean(NappletHostContract.EXTRA_USE_TOR, true)
var canGoBack by remember { mutableStateOf(false) }
@@ -199,7 +200,7 @@ private fun EmbeddedNostrAppTab(
BackHandler(enabled = canGoBack) { controller.back() }
if (showAccess) {
AccessDialog(title, capLabels, websiteMode, useTor) { showAccess = false }
AccessDialog(title, capLabels, profile.exposesNetwork, useTor) { showAccess = false }
}
Scaffold(
@@ -251,7 +252,7 @@ private fun UnavailableTab(
private fun AccessDialog(
title: String,
capLabels: List<String>,
websiteMode: Boolean,
showsNetwork: Boolean,
useTor: Boolean,
onDismiss: () -> Unit,
) {
@@ -262,7 +263,7 @@ private fun AccessDialog(
capLabels.joinToString("\n") { "• $it" }
}
val networkBody =
if (websiteMode) {
if (showsNetwork) {
"\n\n" + stringResource(if (useTor) R.string.favorite_app_network_tor else R.string.favorite_app_network_open)
} else {
""
@@ -0,0 +1,73 @@
/*
* 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.napplethost
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities
/**
* The security posture a sandbox host renders under — the single decision that "locked nApplet vs
* open nSite" actually *is*. Replaces the old scattered `websiteMode` boolean: resolved once in the
* trusted main process, carried over the Intent/Messenger boundary as [name], and every coupled
* consequence (capabilities, CSP, NIP-07, off-origin, network UI) reads from here — so they can't
* drift out of sync, and a third posture is one new constant instead of several edited call sites.
*/
enum class HostProfile {
/** NIP-5D napplet: locked sandbox — declared-only capabilities, strict app CSP, no off-origin, pinned to Tor. */
NAPPLET,
/** NIP-5A nSite ("website mode"): a normal web app — NIP-07 provider, no app CSP, off-origin + per-site Tor toggle. */
WEBSITE,
;
/**
* What this posture is allowed to ask the broker for — THE security decision, minted into the
* launch token in the trusted main process. A website gets the IDENTITY + RELAY pair NIP-07 needs
* (consent-gated); a locked napplet gets only what its manifest `requires` declares.
*/
fun declaredCapabilities(requires: List<String>): Set<NappletCapability> =
when (this) {
WEBSITE -> setOf(NappletCapability.IDENTITY, NappletCapability.RELAY)
NAPPLET -> resolveRequiredCapabilities(requires).capabilities.toSet()
}
/** Strict app CSP for a locked applet (connect-src 'none', etc.); null for a website, which sets its own. */
val appCsp: String?
get() = if (this == NAPPLET) NappletWebContract.APP_CSP else null
/** Install the NIP-07 `window.nostr` provider before the shim runs. */
val injectsNip07: Boolean get() = this == WEBSITE
/** Let off-origin requests reach the network (a locked applet 404s them — its CSP is connect-src 'none'). */
val allowsOffOrigin: Boolean get() = this == WEBSITE
/**
* Apply the WebView SOCKS proxy and expose the Tor/network toggle. A website can re-route over Tor
* or the open web; a locked napplet is pinned to Tor with no toggle.
*/
val exposesNetwork: Boolean get() = this == WEBSITE
companion object {
/** Reconstruct from the wire [name], defaulting to the locked [NAPPLET] posture on anything unknown. */
fun fromName(name: String?): HostProfile = entries.firstOrNull { it.name == name } ?: NAPPLET
}
}
@@ -57,9 +57,9 @@ class NappletContentServer(
// The applet's own per-applet origin (a distinct napplet.local subdomain). The shell is on
// NappletWebContract.ORIGIN; app blobs are served here so the applet has a real, isolated origin.
private val appOrigin: String,
// nSite "website mode": the applet is a normal web app — it gets a NIP-07 window.nostr provider,
// normal network (no app CSP; off-origin requests defer to the WebView), unlike a locked napplet.
private val websiteMode: Boolean = false,
// The host posture: a WEBSITE nSite is a normal web app — NIP-07 window.nostr provider, normal
// network (no app CSP; off-origin requests defer to the WebView), unlike a locked NAPPLET.
private val profile: HostProfile = HostProfile.NAPPLET,
// Embedded surfaces (a windowless Service) can't host the soft keyboard, so the shim installs the
// IME proxy agent that relays the focused field to the host's keyboard. The full-screen Activity
// host has a native keyboard and leaves this false.
@@ -126,7 +126,7 @@ class NappletContentServer(
}
// Off-origin: a locked napplet 404s (connect-src 'none' means it shouldn't ask). An nSite in
// website mode is a normal web app — defer to the WebView so it can load external resources.
return if (websiteMode) null else notFound()
return if (profile.allowsOffOrigin) null else notFound()
}
private fun serveShell(): WebResourceResponse {
@@ -172,7 +172,7 @@ class NappletContentServer(
// Locked napplets get the strict app CSP (connect-src 'none', etc.). An nSite in website mode
// is a normal web app: no app CSP, so it can talk to relays (wss) and load external resources.
val headers = if (websiteMode) emptyMap() else mapOf("Content-Security-Policy" to NappletWebContract.APP_CSP)
val headers = profile.appCsp?.let { mapOf("Content-Security-Policy" to it) } ?: emptyMap()
return WebResourceResponse(
mime,
charset,
@@ -193,7 +193,7 @@ class NappletContentServer(
// theme-independently. Allowed by the app CSP's `style-src 'unsafe-inline'`.
val style = "<style>html,body{overscroll-behavior:none !important}</style>"
// In website mode, set the NIP-07 flag synchronously *before* the shim so window.nostr installs.
val nip07Flag = if (websiteMode) "<script>window.__nappletNip07=true;</script>" else ""
val nip07Flag = if (profile.injectsNip07) "<script>window.__nappletNip07=true;</script>" else ""
// Embedded surface: turn on the IME proxy agent (set before the shim runs).
val imeFlag = if (imeProxy) "<script>window.__nappletImeProxy=true;</script>" else ""
val script = "$style$nip07Flag$imeFlag<script>$shimJs</script>"
@@ -110,8 +110,8 @@ class NappletHostActivity : ComponentActivity() {
// The sandbox never carries its own coordinate, so a compromised :napplet process can't forge one.
private var launchToken: String = ""
// nSite "website mode": a normal web app (NIP-07 window.nostr + normal network), vs a locked napplet.
private var websiteMode: Boolean = false
// The host posture: a WEBSITE nSite (NIP-07 window.nostr + normal network) vs a locked NAPPLET.
private var profile: HostProfile = HostProfile.NAPPLET
// Per-site network choice: route this site's traffic through Tor (default) or over the open web.
// Applied to both the WebView proxy and the blob-fetch client; toggled from the top-bar onion.
@@ -224,7 +224,7 @@ class NappletHostActivity : ComponentActivity() {
// "Open web" for a site makes everything direct — both its blob fetches (here) and its live
// web traffic (the WebView proxy, below). Tor (the default) routes both through the SOCKS port.
val effectiveProxy = if (useTor) proxyPort else -1
contentServer = NappletContentServer(paths, servers, effectiveProxy, cacheDir, shellHtml, shim, appOrigin, websiteMode)
contentServer = NappletContentServer(paths, servers, effectiveProxy, cacheDir, shellHtml, shim, appOrigin, profile)
// Create + warm the WebView NOW so its (slow, first-in-process) Chromium init runs on the main
// thread concurrently with the index probe below (which runs on IO) — instead of serially after
@@ -237,7 +237,7 @@ class NappletHostActivity : ComponentActivity() {
// Route the WebView's own (off-origin) traffic through Tor for an nSite, unless this site was
// opted out to the open web. Set process-wide before any page navigation; the shell + blobs are
// served from cache via shouldInterceptRequest, so only the site's external requests hit this.
if (websiteMode) applyWebViewProxy(effectiveProxy)
if (profile.exposesNetwork) applyWebViewProxy(effectiveProxy)
// Origin-restricted bridge: only the trusted shell page (main frame) can reach native.
WebViewCompat.addWebMessageListener(
webView,
@@ -411,7 +411,7 @@ class NappletHostActivity : ComponentActivity() {
servers.addAll(intent.getStringArrayListExtra(NappletHostContract.EXTRA_SERVERS) ?: emptyList())
author = intent.getStringExtra(NappletHostContract.EXTRA_AUTHOR).orEmpty()
identifier = intent.getStringExtra(NappletHostContract.EXTRA_IDENTIFIER).orEmpty()
websiteMode = intent.getBooleanExtra(NappletHostContract.EXTRA_WEBSITE_MODE, false)
profile = HostProfile.fromName(intent.getStringExtra(NappletHostContract.EXTRA_HOST_PROFILE))
useTor = intent.getBooleanExtra(NappletHostContract.EXTRA_USE_TOR, true)
title = intent.getStringExtra(NappletHostContract.EXTRA_TITLE).orEmpty()
proxyPort = intent.getIntExtra(NappletHostContract.EXTRA_PROXY_PORT, -1)
@@ -731,8 +731,8 @@ class NappletHostActivity : ComponentActivity() {
onReload = { if (this::webView.isInitialized) webView.reload() },
// Website-mode nSites can re-route over Tor; switching rebuilds the session via a confirm
// dialog, so the row taps through rather than toggling inline.
torInitiallyOn = if (websiteMode && proxyPort > 0) useTor else null,
onNetworkTap = if (websiteMode && proxyPort > 0) ({ showNetworkDialog() }) else null,
torInitiallyOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null,
onNetworkTap = if (profile.exposesNetwork && proxyPort > 0) ({ showNetworkDialog() }) else null,
onInfo = { showAccessDialog() },
)
@@ -61,10 +61,11 @@ object NappletHostContract {
const val EXTRA_BG_COLOR = "napplet_bg_color"
/**
* nSite "website mode": treat the content as a normal web app — install the NIP-07 `window.nostr`
* provider and allow normal network (no app CSP). Off for locked napplets.
* The host posture this launch renders under — a [HostProfile] name. WEBSITE treats the content as
* a normal web app (NIP-07 `window.nostr` provider, normal network, no app CSP); NAPPLET is the
* locked sandbox. Resolved in the trusted main process; the sandbox derives all coupled policy from it.
*/
const val EXTRA_WEBSITE_MODE = "napplet_website_mode"
const val EXTRA_HOST_PROFILE = "napplet_host_profile"
/**
* Whether this site's traffic routes through Tor (true, the default when Tor is active) or over the
@@ -89,7 +89,7 @@ class NappletHostService : Service() {
val author: String,
val identifier: String,
val launchToken: String,
val websiteMode: Boolean,
val profile: HostProfile,
val useTor: Boolean,
val proxyPort: Int,
val bgColor: Int,
@@ -218,7 +218,7 @@ class NappletHostService : Service() {
author = author,
identifier = data.getString(NappletHostContract.EXTRA_IDENTIFIER).orEmpty(),
launchToken = launchToken,
websiteMode = data.getBoolean(NappletHostContract.EXTRA_WEBSITE_MODE, false),
profile = HostProfile.fromName(data.getString(NappletHostContract.EXTRA_HOST_PROFILE)),
useTor = data.getBoolean(NappletHostContract.EXTRA_USE_TOR, true),
proxyPort = data.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1),
bgColor = data.getInt(NappletHostContract.EXTRA_BG_COLOR, android.graphics.Color.WHITE),
@@ -255,13 +255,13 @@ class NappletHostService : Service() {
val wv = WebView(context)
val appOrigin = NappletWebContract.appOrigin(deriveAppId(tab.author, tab.identifier))
val effectiveProxy = if (tab.useTor) tab.proxyPort else -1
tab.contentServer = NappletContentServer(tab.paths, tab.servers, effectiveProxy, cacheDir, shellHtml, shimJs, appOrigin, tab.websiteMode, imeProxy = true)
tab.contentServer = NappletContentServer(tab.paths, tab.servers, effectiveProxy, cacheDir, shellHtml, shimJs, appOrigin, tab.profile, imeProxy = true)
hardenWebView(wv, tab)
// Theme the pre-load background so the shell/app loading shows Amethyst's background, not white.
wv.setBackgroundColor(tab.bgColor)
wv.dropSystemBarInsets()
if (tab.websiteMode) applyWebViewProxy(effectiveProxy)
if (tab.profile.exposesNetwork) applyWebViewProxy(effectiveProxy)
WebViewCompat.addWebMessageListener(wv, NappletWebContract.BRIDGE_NAME, setOf(NappletWebContract.ORIGIN), ::onShellMessage)
tab.webView = wv
wv.loadUrl(NappletWebContract.SHELL_URL)