fix(napplets): harden the sandbox trust boundary (identity, private lists, visibility)

Three security hardenings from the nsite/napplet review:

1. Launch-token identity binding. The broker no longer trusts the identity +
   declared capabilities sent on each IPC message from the (less-trusted)
   :napplet process. The main process now mints a random token at launch
   (NappletLaunchRegistry), hands only that to the sandbox, and resolves it back
   to the trusted identity/declared set. A compromised sandbox can act only as
   the napplet it was launched as — closing cross-napplet coordinate spoofing
   (storage + permission ledger).

2. Stop leaking private lists. identity.getMutes/getBlocked read the decrypted
   flow, which includes the user's PRIVATE mutes/blocks. Return only the events'
   public tags (MuteListEvent.publicMutes / PeopleListEvent.publicUsersIdSet).

3. Make grants visible + anti-phishing chrome. A persistent trusted sandbox bar
   (shield + name + tap for "what it can access") the applet can't draw over, and
   a live toast when a granted publish/upload/payment runs — so an allow-always
   grant can't act silently. Also keeps the host out from under the system bars.

Findings + residual risks (session-scoped grants, per-origin resource consent)
tracked in amethyst/plans/2026-06-22-napplet-nsite-security.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-22 18:47:19 +00:00
parent 6a07d21a23
commit ab2ca77998
8 changed files with 322 additions and 50 deletions
@@ -0,0 +1,64 @@
# Napplet / nsite security review (2026-06-22)
A review of the attack surface for NIP-5A static sites (nsites) and NIP-5D napplets,
the protections in place, and the residual risks — with what was fixed in this pass
and what remains as future work.
## Trust model (what holds)
- **Keys never enter the sandbox.** The `:napplet` process holds no account/signer.
Signing happens only in the main process (`signer` fixes `pubkey`, host clock
prevents backdating). *"Even a full WebView/renderer escape into this process yields
no secret."*
- **Content integrity.** Every blob is sha256-verified against the **signed** manifest
before serving (`StaticSiteResolver`); cache is content-addressed + re-verified, so a
poisoned/stale cache can't be served. nsites launch with **zero** capabilities.
- **Network containment.** App CSP `connect-src 'none'`; egress only via the brokered,
consent-gated `resource.bytes`, Tor-routed. Bridge is origin-restricted + main-frame.
- **IPC binding** is `exported=false` + same-UID (`onBind` UID check). Payments always
prompt per-use with the amount.
## Fixed in this pass
1. **Cross-napplet identity/storage spoofing (was: per-message identity).** The broker
used to trust `author`/`identifier`/`declared` sent on **every** IPC message from the
`:napplet` process. A WebView→native escape could forge another napplet's coordinate
and read/act as it. Now the **main process** mints a random launch token
(`NappletLaunchRegistry`), hands only that token to the sandbox, and the broker
resolves it back to the trusted identity + declared set. A compromised sandbox can act
as nothing but the napplet it was launched as (it holds only its own token).
2. **Private mute/block leak via `identity.getMutes`/`getBlocked`.** These read
`muteList.flow` / `blockPeopleList.flow`, which contain **decrypted private** entries.
Now they read the events' **public** tags only (`MuteListEvent.publicMutes()`,
`PeopleListEvent.publicUsersIdSet()`).
3. **Silent "allow-always" actions + UI-redress.** Added persistent **trusted chrome**
(a sandbox bar the applet can't draw over: shield + name + tap-to-see "what it can
access") and a **live toast** when a granted RELAY/UPLOAD/VALUE op runs — so an
allow-always grant can't act completely silently. Also fixes the host drawing under
the status/navigation bars (edge-to-edge insets).
## Residual risks / future work
- **Coarse, persistent grants.** Non-payment capabilities persist as ALLOW_ALWAYS. A
consented napplet can still, thereafter, publish as you (RELAY), read your social graph
(IDENTITY), and make arbitrary network calls (RESOURCE) without re-prompting. The new
chrome + toasts make this *visible*, but the model is still allow-forever. **Proposed:**
a session-scoped grant ("Allow while open", cleared on close) in `GrantState` +
consent dialog, defaulted for RESOURCE/RELAY; and per-origin consent for cross-origin
`resource.bytes` https fetches.
- **`resource.bytes` as exfil channel.** Once RESOURCE is allow-always, the applet can
encode data into arbitrary https URLs through the Tor proxy. Tor hides the IP, not the
payload. Tie to the per-origin/session proposal above.
- **`'unsafe-inline'` in app `script-src`.** Required to inject the shim; the applet is
the author's own (content sha256-pinned), so it's not an escalation. `connect-src
'none'` remains the real boundary. Residual, accepted.
- **Trust pivots on the author key.** A compromised author key lets an attacker push a
new *signed* manifest and own the app — expected Nostr trust model. Aggregate `x` hash
is enforced when present (only *recommended* by the spec); per-path hashes always
protect.
- **Launch-token lifecycle.** Tokens are capped (LRU, 128) rather than explicitly
unregistered on sandbox close (the sandbox is a separate process and can't reach the
main-process registry). A long-backgrounded napplet whose token was evicted would need
relaunch. Acceptable; revisit if it bites.
@@ -34,8 +34,6 @@ import android.os.RemoteException
import android.util.Log
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
@@ -110,15 +108,19 @@ class NappletBrokerService : Service() {
val requestId = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
val identity =
NappletIdentity(
authorPubKey = data.getString(NappletIpc.KEY_AUTHOR).orEmpty(),
identifier = data.getString(NappletIpc.KEY_IDENTIFIER).orEmpty(),
aggregateHash = data.getString(NappletIpc.KEY_AGGREGATE_HASH),
)
val declared = parseDeclared(data.getString(NappletIpc.KEY_DECLARED))
val requestType = runCatching { NappletProtocolJson.readType(payload) }.getOrNull() ?: "napplet"
// Resolve the launch token to the trusted identity + declared set. The sandbox never states
// its own coordinate, so a compromised :napplet process can only ever act as the napplet it
// was launched as (it holds only its own token). An unknown token = no session; refuse.
val session = NappletLaunchRegistry.resolve(data.getString(NappletIpc.KEY_LAUNCH_TOKEN))
if (session == null) {
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Unknown napplet session.")))
return true
}
val identity = session.identity
val declared = session.declared
scope.launch {
// The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply
// decision (it stays wire-identical with the future desktop host). This service only supplies
@@ -141,13 +143,6 @@ class NappletBrokerService : Service() {
return true
}
private fun parseDeclared(value: String?): Set<NappletCapability> =
value
?.split(',')
?.mapNotNull { name -> runCatching { NappletCapability.valueOf(name.trim()) }.getOrNull() }
?.toSet()
?: emptySet()
/**
* The broker for the *currently* signed-in account, cached and rebuilt only when the account
* changes (reference identity). The gateways capture the account and read its flows live, so a
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.napplet
import android.app.AlertDialog
import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
@@ -31,14 +32,20 @@ import android.os.Looper
import android.os.Message
import android.os.Messenger
import android.util.Log
import android.util.TypedValue
import android.view.Gravity
import android.view.KeyEvent
import android.view.View
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.webkit.JavaScriptReplyProxy
@@ -46,6 +53,7 @@ import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities
@@ -72,11 +80,10 @@ class NappletHostActivity : ComponentActivity() {
private val paths = mutableListOf<PathTag>()
private val servers = mutableListOf<String>()
private var author: String = ""
private var identifier: String = ""
private var aggregateHash: String? = null
// Capability names (comma-separated) the manifest declared; the broker refuses anything else.
private var declared: String = ""
// Opaque token the broker resolves to this launch's trusted identity + declared capabilities.
// The sandbox never carries its own coordinate, so a compromised :napplet process can't forge one.
private var launchToken: String = ""
// NAP domain strings the shell advertises to the applet in the shell.init handshake.
private var declaredDomains: List<String> = emptyList()
@@ -140,15 +147,25 @@ class NappletHostActivity : ComponentActivity() {
contentServer = NappletContentServer(paths, servers, proxyPort, cacheDir, shellHtml, shim)
webView = WebView(this)
setContentView(webView)
// Activities are edge-to-edge by default on recent Android; pad the WebView by the system bar
// and display-cutout insets so the applet's own content isn't drawn under the status/nav bars.
ViewCompat.setOnApplyWindowInsetsListener(webView) { view, insets ->
hardenWebView(webView)
// Persistent trusted chrome: a sandbox bar the applet can't draw over (anti-phishing) showing
// the napplet's name and a tap-to-see "what it can access". Below it, the applet's WebView.
val root =
LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
addView(buildSandboxBar())
addView(buildDivider())
addView(webView, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f))
}
setContentView(root)
// Activities are edge-to-edge by default on recent Android; pad by the system bar and
// display-cutout insets so neither the chrome nor the applet draws under the system bars.
ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
insets
}
hardenWebView(webView)
// Origin-restricted bridge: only the trusted shell page (main frame) can reach native.
WebViewCompat.addWebMessageListener(
@@ -213,18 +230,17 @@ class NappletHostActivity : ComponentActivity() {
for (i in pathList.indices) paths.add(PathTag(pathList[i], hashList[i]))
servers.addAll(intent.getStringArrayListExtra(NappletLauncher.EXTRA_SERVERS) ?: emptyList())
author = intent.getStringExtra(NappletLauncher.EXTRA_AUTHOR).orEmpty()
identifier = intent.getStringExtra(NappletLauncher.EXTRA_IDENTIFIER).orEmpty()
aggregateHash = intent.getStringExtra(NappletLauncher.EXTRA_AGGREGATE_HASH)
title = intent.getStringExtra(NappletLauncher.EXTRA_TITLE).orEmpty()
proxyPort = intent.getIntExtra(NappletLauncher.EXTRA_PROXY_PORT, -1)
launchToken = intent.getStringExtra(NappletLauncher.EXTRA_LAUNCH_TOKEN).orEmpty()
val requires = intent.getStringArrayListExtra(NappletLauncher.EXTRA_REQUIRES) ?: emptyList()
val resolved = resolveRequiredCapabilities(requires)
declared = resolved.capabilities.joinToString(",") { it.name }
// shell is always available; the rest are the declared domains the broker will honor.
// shell is always available; the rest are the declared domains advertised to the applet in the
// handshake. (The broker enforces the authoritative set from the launch token, not this list.)
declaredDomains = (listOf("shell") + resolved.capabilities.map { it.name.lowercase() }).distinct()
return author.isNotEmpty()
return author.isNotEmpty() && launchToken.isNotEmpty()
}
private var title: String = ""
@@ -323,10 +339,7 @@ class NappletHostActivity : ComponentActivity() {
Bundle().apply {
putString(NappletIpc.KEY_REQUEST_ID, id)
putString(NappletIpc.KEY_PAYLOAD, raw)
putString(NappletIpc.KEY_AUTHOR, author)
putString(NappletIpc.KEY_IDENTIFIER, identifier)
putString(NappletIpc.KEY_AGGREGATE_HASH, aggregateHash)
putString(NappletIpc.KEY_DECLARED, declared)
putString(NappletIpc.KEY_LAUNCH_TOKEN, launchToken)
}
}
@@ -362,6 +375,7 @@ class NappletHostActivity : ComponentActivity() {
val actionId = result.optString("actionId")
if (actionId.isNotEmpty()) keyActions.register(actionId, result.optString("binding").ifEmpty { null })
}
notifyIfSensitive(result)
bridgeReplyProxy?.postMessage(result.toString())
}
// A subscription push (relay.event/relay.eose) is keyed by subId, not a request id; forward verbatim.
@@ -374,6 +388,100 @@ class NappletHostActivity : ComponentActivity() {
return true
}
// ---- trusted sandbox chrome ----
private fun barTitle(): String = title.ifBlank { getString(R.string.napplet_untitled) }
/** The always-visible bar: a shield, the napplet's name, and an info affordance to see its access. */
private fun buildSandboxBar(): View {
val onSurface = resolveThemeColor(android.R.attr.textColorPrimary)
val bar =
LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground))
setPadding(dp(14), dp(10), dp(14), dp(10))
isClickable = true
setOnClickListener { showAccessDialog() }
contentDescription = getString(R.string.napplet_chrome_permissions_desc)
}
bar.addView(
TextView(this).apply {
text = "🛡" // shield
setPadding(0, 0, dp(10), 0)
},
)
bar.addView(
TextView(this).apply {
text = barTitle()
setTextColor(onSurface)
textSize = 16f
maxLines = 1
ellipsize = android.text.TextUtils.TruncateAt.END
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
},
)
bar.addView(
TextView(this).apply {
text = "" // circled info
setTextColor(onSurface)
textSize = 18f
},
)
return bar
}
private fun buildDivider(): View =
View(this).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(1))
setBackgroundColor(resolveThemeColor(android.R.attr.textColorPrimary) and 0x22FFFFFF)
}
/** Lists, in plain language, exactly which capability domains this napplet was launched with. */
private fun showAccessDialog() {
val caps =
declaredDomains
.filter { it != "shell" }
.mapNotNull { NappletCapability.fromNapDomain(it) }
.map { getString(it.labelRes()) }
val body =
if (caps.isEmpty()) {
getString(R.string.napplet_chrome_static_site)
} else {
caps.joinToString("\n") { "$it" } + "\n\n" + getString(R.string.napplet_chrome_keys_safe)
}
AlertDialog
.Builder(this)
.setTitle(getString(R.string.napplet_chrome_access_title, barTitle()))
.setMessage(body)
.setPositiveButton(android.R.string.ok, null)
.show()
}
/**
* Surfaces an "allow always" capability acting on the user's behalf, so a granted RELAY/UPLOAD/VALUE
* op can never run completely silently. Read-only ops (identity/storage/resource) don't toast.
*/
private fun notifyIfSensitive(result: JSONObject) {
if (!result.optBoolean("ok")) return
val message =
when (result.optString("type")) {
"relay.publish.result", "relay.publishEncrypted.result" -> getString(R.string.napplet_action_published, barTitle())
"upload.upload.result" -> getString(R.string.napplet_action_uploaded, barTitle())
"value.payInvoice.result" -> getString(R.string.napplet_action_paid, barTitle())
else -> return
}
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
private fun resolveThemeColor(attr: Int): Int {
val tv = TypedValue()
theme.resolveAttribute(attr, tv, true)
return if (tv.resourceId != 0) ContextCompat.getColor(this, tv.resourceId) else tv.data
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
companion object {
private const val TAG = "NappletHostActivity"
}
@@ -43,13 +43,10 @@ object NappletIpc {
const val KEY_REQUEST_ID = "requestId"
const val KEY_PAYLOAD = "payload"
// Applet identity (the ledger coordinate) travels with every request — the host cannot be
// trusted to have applied any policy, so the broker re-derives everything from these.
const val KEY_AUTHOR = "author"
const val KEY_IDENTIFIER = "identifier"
const val KEY_AGGREGATE_HASH = "aggregateHash"
/** Capability names (comma-separated) the manifest's `requires` resolved to. The broker
* refuses any request outside this set, regardless of consent state. */
const val KEY_DECLARED = "declared"
/**
* Opaque per-launch token. The sandbox cannot be trusted to state its own identity, so it sends
* only this token; the broker resolves it through [NappletLaunchRegistry] (main process) back to
* the trusted identity + declared capability set the launch was registered with.
*/
const val KEY_LAUNCH_TOKEN = "launchToken"
}
@@ -0,0 +1,76 @@
/*
* 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.napplet
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import java.security.SecureRandom
/**
* Main-process registry that binds a sandbox launch to its **trusted** identity + declared capability
* set, addressed by an unguessable launch token.
*
* Why: the broker (main process) must know *which* napplet a brokered request belongs to, but the
* request travels up from the sandboxed `:napplet` process, which is the very thing we don't fully
* trust (a WebView/renderer escape runs code there). If the identity rode along inside each IPC
* message, a compromised sandbox could simply claim another napplet's coordinate and read/act as it.
*
* Instead, [NappletLauncher] (main process) mints a random token here at launch time, hands only that
* token to the sandbox, and the broker resolves it back to the identity it was registered with. The
* sandbox only ever holds *its own* token, so even a fully compromised `:napplet` process can act as
* nothing but the napplet it was launched as.
*
* Both the launcher and [NappletBrokerService] run in the main process, so they share this singleton.
*/
object NappletLaunchRegistry {
data class Session(
val identity: NappletIdentity,
val declared: Set<NappletCapability>,
)
// Access-ordered + capped so tokens from long-closed napplets can't accumulate without bound. The
// active napplet always re-touches its token, so only stale sessions are ever evicted.
private const val MAX_SESSIONS = 128
private val sessions =
object : LinkedHashMap<String, Session>(16, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<String, Session>) = size > MAX_SESSIONS
}
private val secureRandom = SecureRandom()
@Synchronized
fun register(
identity: NappletIdentity,
declared: Set<NappletCapability>,
): String {
val token = ByteArray(32).also(secureRandom::nextBytes).toHexKey()
sessions[token] = Session(identity, declared)
return token
}
@Synchronized
fun resolve(token: String?): Session? = token?.let { sessions[it] }
@Synchronized
fun unregister(token: String?) {
token?.let { sessions.remove(it) }
}
}
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.napplet
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
@@ -47,6 +49,13 @@ object NappletLauncher {
/** Bare NAP capability domains the manifest declared (empty for a plain nsite). */
const val EXTRA_REQUIRES = "napplet_requires"
/**
* Unguessable token for this launch. The sandbox relays it to the broker, which resolves it back
* to the trusted identity + declared capabilities via [NappletLaunchRegistry] the sandbox never
* carries (and so can never forge) its own coordinate.
*/
const val EXTRA_LAUNCH_TOKEN = "napplet_launch_token"
/** SOCKS proxy port to route blob fetches through, or -1 for a direct connection. */
const val EXTRA_PROXY_PORT = "napplet_proxy_port"
@@ -92,6 +101,12 @@ object NappletLauncher {
}.getOrNull().orEmpty()
val allServers = (servers + authorBlossomServers).distinct()
// 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 = resolveRequiredCapabilities(requires).capabilities.toSet()
val launchToken = NappletLaunchRegistry.register(identity, declared)
val intent =
Intent(context, NappletHostActivity::class.java).apply {
putExtra(EXTRA_PATHS, ArrayList(paths.map { it.path }))
@@ -102,6 +117,7 @@ object NappletLauncher {
putExtra(EXTRA_AGGREGATE_HASH, aggregateHash)
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_REQUIRES, ArrayList(requires))
putExtra(EXTRA_LAUNCH_TOKEN, launchToken)
putExtra(EXTRA_PROXY_PORT, proxyPort)
if (context !is android.app.Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
@@ -58,15 +58,22 @@ class AccountIdentityReader(
"getFollows" -> jsonStringArray(account.kind3FollowList.flow.value.authors)
"getMutes" ->
jsonStringArray(
account.muteList.flow.value
.filterIsInstance<UserTag>()
.map { it.pubKey },
// PUBLIC mutes only — the decrypted flow (muteList.flow) also holds the user's
// private mutes, which a napplet must never see. Read the event's plaintext tags.
account.muteList
.getMuteList()
?.publicMutes()
?.filterIsInstance<UserTag>()
?.map { it.pubKey }
.orEmpty(),
)
"getBlocked" ->
jsonStringArray(
account.blockPeopleList.flow.value
.filterIsInstance<UserTag>()
.map { it.pubKey },
// PUBLIC blocks only, for the same reason as getMutes.
account.blockPeopleList
.getBlockList()
?.publicUsersIdSet()
.orEmpty(),
)
"getRelays" -> relaysJson()
"getList" -> listJson(argument)
+9
View File
@@ -675,6 +675,15 @@
<string name="napplet_invalid">Invalid napplet.</string>
<string name="napplet_webview_too_old">This device\'s WebView is too old to run napplets safely.</string>
<string name="napplet_fallback_title">Napplet %1$s…</string>
<!-- Napplet sandbox chrome (host top bar + live action notices) -->
<string name="napplet_chrome_access_title">What “%1$s” can access</string>
<string name="napplet_chrome_keys_safe">It can never read your keys, and every sign, publish, upload, or payment was approved by you. Manage access in Settings ▸ Napplets.</string>
<string name="napplet_chrome_static_site">Static site — it has no special access to your account.</string>
<string name="napplet_chrome_permissions_desc">What this app can access</string>
<string name="napplet_action_published">“%1$s” published a note as you</string>
<string name="napplet_action_uploaded">“%1$s” uploaded a file</string>
<string name="napplet_action_paid">“%1$s” made a payment</string>
<!-- Napplet capability names -->
<string name="napplet_cap_shell">Shell</string>
<string name="napplet_cap_identity">Identity</string>