mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
Align napplet host with current NIP-5D and NAPs
This commit is contained in:
@@ -165,27 +165,17 @@ object FavoriteAppLauncher {
|
||||
return when (event) {
|
||||
is RootNappletEvent ->
|
||||
NappletLauncher.buildLaunchParams(
|
||||
context,
|
||||
event.paths(),
|
||||
event.servers(),
|
||||
event.pubKey,
|
||||
"",
|
||||
event.declaredAggregateHash() ?: event.computeAggregateHash(),
|
||||
event.title() ?: "Napplet",
|
||||
event.requires(),
|
||||
HostProfile.NAPPLET,
|
||||
context = context,
|
||||
manifest = event,
|
||||
authorPubKey = event.pubKey,
|
||||
identifier = "",
|
||||
)
|
||||
is NamedNappletEvent ->
|
||||
NappletLauncher.buildLaunchParams(
|
||||
context,
|
||||
event.paths(),
|
||||
event.servers(),
|
||||
event.pubKey,
|
||||
event.identifier(),
|
||||
event.declaredAggregateHash() ?: event.computeAggregateHash(),
|
||||
event.title() ?: event.identifier(),
|
||||
event.requires(),
|
||||
HostProfile.NAPPLET,
|
||||
context = context,
|
||||
manifest = event,
|
||||
authorPubKey = event.pubKey,
|
||||
identifier = event.identifier(),
|
||||
)
|
||||
is RootSiteEvent ->
|
||||
NappletLauncher.buildLaunchParams(
|
||||
|
||||
+26
-1
@@ -57,7 +57,27 @@ class DataStoreNappletStorage(
|
||||
key: String,
|
||||
value: String,
|
||||
) {
|
||||
dataStore.edit { it[keyOf(coordinate, key)] = value }
|
||||
dataStore.edit { preferences ->
|
||||
val prefix = prefixOf(coordinate)
|
||||
val target = keyOf(coordinate, key)
|
||||
val currentBytes =
|
||||
preferences
|
||||
.asMap()
|
||||
.entries
|
||||
.asSequence()
|
||||
.filter { it.key.name.startsWith(prefix) }
|
||||
.sumOf { (storedKey, storedValue) ->
|
||||
storedKey.name
|
||||
.removePrefix(prefix)
|
||||
.encodeToByteArray()
|
||||
.size +
|
||||
((storedValue as? String)?.encodeToByteArray()?.size ?: 0)
|
||||
}
|
||||
val replacedBytes = key.encodeToByteArray().size + (preferences[target]?.encodeToByteArray()?.size ?: 0)
|
||||
val proposedBytes = currentBytes - replacedBytes + key.encodeToByteArray().size + value.encodeToByteArray().size
|
||||
require(proposedBytes <= MAX_STORAGE_BYTES) { "Napplet storage quota exceeded." }
|
||||
preferences[target] = value
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun remove(
|
||||
@@ -87,4 +107,9 @@ class DataStoreNappletStorage(
|
||||
coordinate: String,
|
||||
key: String,
|
||||
) = stringPreferencesKey(prefixOf(coordinate) + key)
|
||||
|
||||
companion object {
|
||||
/** NAP-STORAGE's recommended per-napplet UTF-8 quota. */
|
||||
const val MAX_STORAGE_BYTES = 512 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.napplethost.NappletIpc
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -58,6 +59,7 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* The trust boundary's main-process endpoint. The untrusted `:napplet` process binds this
|
||||
@@ -93,7 +95,11 @@ class NappletBrokerService : Service() {
|
||||
|
||||
// Live relay subscriptions, keyed by the applet's subId. The account comes per-open from the
|
||||
// requesting surface's launch token, so a surface's REQs always target the account it acts as.
|
||||
private val liveSubscriptions = NappletLiveSubscriptions()
|
||||
private val liveSubscriptions = NappletLiveSubscriptions(scope)
|
||||
|
||||
// NAP-RESOURCE cancellation is keyed by the trusted launch token plus the caller's request id.
|
||||
// Cancelling removes the job before it can emit a late terminal envelope to the sandbox.
|
||||
private val resourceRequests = ConcurrentHashMap<String, Job>()
|
||||
|
||||
// The app-wide inc pub/sub bus: routes inc.emit between live napplet sessions as inc.event pushes.
|
||||
private val incBus = NappletIncBus { replyTo, payload -> push(replyTo, payload) }
|
||||
@@ -117,7 +123,7 @@ class NappletBrokerService : Service() {
|
||||
|
||||
override fun onDestroy() {
|
||||
liveSubscriptions.closeAll()
|
||||
identityWatch.stop()
|
||||
identityWatch.stopAll()
|
||||
// Every applet/browser surface has unbound, so the "session" the user granted for is over.
|
||||
// The ledger and the broker cache are now app-wide singletons that outlive this service, so
|
||||
// their in-memory session grants have to be dropped explicitly here — that keeps the lifetime
|
||||
@@ -280,38 +286,57 @@ class NappletBrokerService : Service() {
|
||||
// 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))
|
||||
val launchToken = data.getString(NappletIpc.KEY_LAUNCH_TOKEN)
|
||||
val session = NappletLaunchRegistry.resolve(launchToken)
|
||||
if (session == null) {
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Unknown napplet session.")))
|
||||
return true
|
||||
}
|
||||
val identity = session.identity
|
||||
val declared = session.declared
|
||||
val resourceRequestKey = "$launchToken\u0000$requestId"
|
||||
if (requestType == "resource.cancel") {
|
||||
resourceRequests.remove(resourceRequestKey)?.cancel()
|
||||
return true
|
||||
}
|
||||
val tracksResourceRequest = requestType == "resource.bytes" || requestType == "resource.bytesMany"
|
||||
|
||||
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
|
||||
// the broker, the Messenger transport, and the live relay subscription each Outcome implies.
|
||||
// The launch token decides whose key signs — not the active account. A surface opened by
|
||||
// one account can never be handed another's signer, even while it stays open across a switch.
|
||||
val broker = brokerFor(session.accountPubKey)
|
||||
if (broker == null) {
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in.")))
|
||||
return@launch
|
||||
}
|
||||
when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) {
|
||||
is NappletRequestRouter.Outcome.Ignore -> {}
|
||||
is NappletRequestRouter.Outcome.Reply -> reply(replyTo, requestId, outcome.payload)
|
||||
is NappletRequestRouter.Outcome.OpenSubscription ->
|
||||
liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId)
|
||||
is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start(session.accountPubKey) { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.UnwatchIdentity -> identityWatch.stop()
|
||||
is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic)
|
||||
is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic)
|
||||
is NappletRequestRouter.Outcome.EmitInc -> incBus.emit(replyTo, identity.coordinate, outcome.topic, outcome.payloadRaw)
|
||||
val requestJob =
|
||||
scope.launch(start = if (tracksResourceRequest) CoroutineStart.LAZY else CoroutineStart.DEFAULT) {
|
||||
// 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
|
||||
// the broker, the Messenger transport, and the live relay subscription each Outcome implies.
|
||||
// The launch token decides whose key signs — not the active account. A surface opened by
|
||||
// one account can never be handed another's signer, even while it stays open across a switch.
|
||||
val broker = brokerFor(session.accountPubKey)
|
||||
if (broker == null) {
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in.")))
|
||||
return@launch
|
||||
}
|
||||
when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) {
|
||||
is NappletRequestRouter.Outcome.Ignore -> {}
|
||||
is NappletRequestRouter.Outcome.Reply -> {
|
||||
reply(replyTo, requestId, outcome.payload)
|
||||
// NAP-IDENTITY has no watch/unwatch request. Once the consent-gated startup
|
||||
// snapshot succeeds, the runtime owns identity.changed delivery for this
|
||||
// trusted launch token until the broker service closes.
|
||||
if (requestType == "identity.getPublicKey" && outcome.payload.contains("\"ok\":true") && launchToken != null) {
|
||||
identityWatch.start(launchToken, session.accountPubKey) { push(replyTo, it) }
|
||||
}
|
||||
}
|
||||
is NappletRequestRouter.Outcome.OpenSubscription ->
|
||||
liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId)
|
||||
is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic)
|
||||
is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic)
|
||||
is NappletRequestRouter.Outcome.EmitInc -> incBus.emit(replyTo, identity.coordinate, outcome.topic, outcome.payloadRaw)
|
||||
}
|
||||
}
|
||||
if (tracksResourceRequest) {
|
||||
resourceRequests.put(resourceRequestKey, requestJob)?.cancel()
|
||||
requestJob.invokeOnCompletion { resourceRequests.remove(resourceRequestKey, requestJob) }
|
||||
requestJob.start()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
@StringRes
|
||||
fun NappletCapability.labelRes(): Int =
|
||||
when (this) {
|
||||
NappletCapability.SHELL -> R.string.napplet_cap_shell
|
||||
NappletCapability.IDENTITY -> R.string.napplet_cap_identity
|
||||
NappletCapability.KEYS -> R.string.napplet_cap_keys
|
||||
NappletCapability.RELAY -> R.string.napplet_cap_relay
|
||||
@@ -45,7 +44,6 @@ fun NappletCapability.labelRes(): Int =
|
||||
@StringRes
|
||||
fun NappletCapability.descriptionRes(): Int =
|
||||
when (this) {
|
||||
NappletCapability.SHELL -> R.string.napplet_cap_shell_desc
|
||||
NappletCapability.IDENTITY -> R.string.napplet_cap_identity_desc
|
||||
NappletCapability.KEYS -> R.string.napplet_cap_keys_desc
|
||||
NappletCapability.RELAY -> R.string.napplet_cap_relay_desc
|
||||
|
||||
@@ -294,9 +294,10 @@ class NappletConsentSummary(
|
||||
context.getString(R.string.napplet_consent_pay_no_amount)
|
||||
}
|
||||
}
|
||||
is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource)
|
||||
NappletRequest.ResourceInfo, is NappletRequest.ResourceBytes, is NappletRequest.ResourceBytesMany ->
|
||||
context.getString(R.string.napplet_consent_resource)
|
||||
is NappletRequest.UploadBlob -> context.getString(R.string.napplet_consent_upload)
|
||||
// Resolved in the broker before consent (negotiation / shell-mediated / cosmetic); never shown.
|
||||
is NappletRequest.ShellSupports, is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> ""
|
||||
is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,21 +34,22 @@ import kotlinx.coroutines.launch
|
||||
* value is dropped — the applet already has it via `getPublicKey`), encodes and pushes the new key
|
||||
* (or `""` when no account is signed in) to the caller-supplied sink.
|
||||
*
|
||||
* One watch at a time per host binding; [start] replaces any prior one. Reached only after the
|
||||
* router confirmed the applet declared the IDENTITY capability.
|
||||
* Watches are keyed by the trusted launch token so concurrent surfaces cannot replace each other's
|
||||
* streams. A watch starts only after that surface successfully obtains its public-key snapshot.
|
||||
*/
|
||||
class NappletIdentityWatch(
|
||||
private val scope: CoroutineScope,
|
||||
private val pubKey: (boundPubKey: String) -> Flow<String>,
|
||||
) {
|
||||
private var job: Job? = null
|
||||
private val jobs = mutableMapOf<String, Job>()
|
||||
|
||||
fun start(
|
||||
watchId: String,
|
||||
boundPubKey: String,
|
||||
push: (String) -> Unit,
|
||||
) {
|
||||
stop()
|
||||
job =
|
||||
if (jobs.containsKey(watchId)) return
|
||||
jobs[watchId] =
|
||||
scope.launch {
|
||||
pubKey(boundPubKey)
|
||||
.distinctUntilChanged()
|
||||
@@ -57,8 +58,8 @@ class NappletIdentityWatch(
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
fun stopAll() {
|
||||
jobs.values.forEach { it.cancel() }
|
||||
jobs.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ object NappletLaunchRegistry {
|
||||
accountPubKey: HexKey,
|
||||
): String {
|
||||
val token = ByteArray(32).also(secureRandom::nextBytes).toHexKey()
|
||||
sessions[token] = Session(identity, declared, accountPubKey)
|
||||
sessions[token] = Session(identity.copy(instanceId = token), declared, accountPubKey)
|
||||
return token
|
||||
}
|
||||
|
||||
|
||||
@@ -24,14 +24,18 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletArtifactPolicy
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
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.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
@@ -49,20 +53,18 @@ object NappletLauncher {
|
||||
manifest: NappletManifest,
|
||||
authorPubKey: HexKey,
|
||||
identifier: String,
|
||||
) = launch(
|
||||
context = context,
|
||||
paths = manifest.paths(),
|
||||
servers = manifest.servers(),
|
||||
authorPubKey = authorPubKey,
|
||||
identifier = identifier,
|
||||
aggregateHash = manifest.declaredAggregateHash() ?: manifest.computeAggregateHash(),
|
||||
title = manifest.title() ?: identifier.ifBlank { "Napplet" },
|
||||
requires = manifest.requires(),
|
||||
)
|
||||
) {
|
||||
val event = manifest as? Event
|
||||
if (event?.verify() != true || event.pubKey != authorPubKey) {
|
||||
Log.w(TAG, "Refusing NIP-5D manifest that failed signature/author verification")
|
||||
return
|
||||
}
|
||||
buildLaunchParams(context, manifest, authorPubKey, identifier)?.let { openHost(context, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens any NIP-5A static site (nsite or napplet). [requires] is empty for a plain nsite —
|
||||
* the broker then refuses every capability, so the site renders as inert static content.
|
||||
* Opens a NIP-5A website from its already-resolved path data. NIP-5D napplets use the verified
|
||||
* manifest overload so raw callers cannot bypass signature/author validation.
|
||||
*/
|
||||
fun launch(
|
||||
context: Context,
|
||||
@@ -73,12 +75,26 @@ object NappletLauncher {
|
||||
aggregateHash: HexKey?,
|
||||
title: String,
|
||||
requires: List<String>,
|
||||
// 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,
|
||||
// Raw path data is accepted only for the legacy NIP-5A website profile. NIP-5D callers must
|
||||
// use the signature-checking manifest overload above.
|
||||
profile: HostProfile,
|
||||
) {
|
||||
if (profile != HostProfile.WEBSITE) {
|
||||
Log.w(TAG, "Refusing raw NIP-5D launch without a verified manifest")
|
||||
return
|
||||
}
|
||||
val params =
|
||||
runCatching { buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile) }
|
||||
.onFailure { Log.w(TAG, "Refusing invalid ${profile.name.lowercase()} launch", it) }
|
||||
.getOrNull()
|
||||
?: return
|
||||
openHost(context, params)
|
||||
}
|
||||
|
||||
private fun openHost(
|
||||
context: Context,
|
||||
params: Bundle,
|
||||
) {
|
||||
val params = buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile)
|
||||
val intent =
|
||||
Intent(context, NappletHostActivity::class.java).apply {
|
||||
putExtras(params)
|
||||
@@ -105,6 +121,29 @@ object NappletLauncher {
|
||||
requires: List<String>,
|
||||
profile: HostProfile,
|
||||
): Bundle {
|
||||
require(profile == HostProfile.WEBSITE) { "NIP-5D launch parameters require a verified manifest." }
|
||||
return buildLaunchParamsTrusted(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile)
|
||||
}
|
||||
|
||||
private fun buildLaunchParamsTrusted(
|
||||
context: Context,
|
||||
paths: List<PathTag>,
|
||||
servers: List<String>,
|
||||
authorPubKey: HexKey,
|
||||
identifier: String,
|
||||
aggregateHash: HexKey?,
|
||||
title: String,
|
||||
requires: List<String>,
|
||||
profile: HostProfile,
|
||||
): Bundle {
|
||||
val effectiveAggregateHash =
|
||||
if (profile == HostProfile.NAPPLET) {
|
||||
requireNotNull(NappletArtifactPolicy.verifiedAggregateHash(paths, aggregateHash)) {
|
||||
"NIP-5D requires one self-contained /index.html with a valid blob hash and matching aggregate."
|
||||
}
|
||||
} else {
|
||||
aggregateHash
|
||||
}
|
||||
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
|
||||
|
||||
// Augment the manifest's servers with the author's published Blossom list (kind:10063), if
|
||||
@@ -118,7 +157,7 @@ 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 identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = effectiveAggregateHash)
|
||||
val declared = profile.declaredCapabilities(requires)
|
||||
// Bound to the account launching it, so the surface keeps signing as that account even if the
|
||||
// user switches while it is open (an embedded surface is rebuilt on a switch and re-mints).
|
||||
@@ -156,7 +195,7 @@ object NappletLauncher {
|
||||
putStringArrayList(NappletHostContract.EXTRA_SERVERS, ArrayList(allServers))
|
||||
putString(NappletHostContract.EXTRA_AUTHOR, authorPubKey)
|
||||
putString(NappletHostContract.EXTRA_IDENTIFIER, identifier)
|
||||
putString(NappletHostContract.EXTRA_AGGREGATE_HASH, aggregateHash)
|
||||
putString(NappletHostContract.EXTRA_AGGREGATE_HASH, effectiveAggregateHash)
|
||||
putString(NappletHostContract.EXTRA_TITLE, title)
|
||||
putStringArrayList(NappletHostContract.EXTRA_REQUIRES, ArrayList(requires))
|
||||
putStringArrayList(NappletHostContract.EXTRA_CAP_LABELS, ArrayList(capLabels))
|
||||
@@ -170,4 +209,34 @@ object NappletLauncher {
|
||||
putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
|
||||
}
|
||||
}
|
||||
|
||||
/** Signature-checking entry point for embedded NIP-5D surfaces. */
|
||||
fun buildLaunchParams(
|
||||
context: Context,
|
||||
manifest: NappletManifest,
|
||||
authorPubKey: HexKey,
|
||||
identifier: String,
|
||||
): Bundle? {
|
||||
val event = manifest as? Event
|
||||
if (event?.verify() != true || event.pubKey != authorPubKey) {
|
||||
Log.w(TAG, "Refusing embedded NIP-5D manifest that failed signature/author verification")
|
||||
return null
|
||||
}
|
||||
return runCatching {
|
||||
buildLaunchParamsTrusted(
|
||||
context = context,
|
||||
paths = manifest.paths(),
|
||||
servers = manifest.servers(),
|
||||
authorPubKey = authorPubKey,
|
||||
identifier = identifier,
|
||||
aggregateHash = manifest.declaredAggregateHash() ?: manifest.computeAggregateHash(),
|
||||
title = manifest.title() ?: identifier.ifBlank { "Napplet" },
|
||||
requires = manifest.requires(),
|
||||
profile = HostProfile.NAPPLET,
|
||||
)
|
||||
}.onFailure { Log.w(TAG, "Refusing invalid embedded NIP-5D launch", it) }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
private const val TAG = "NappletLauncher"
|
||||
}
|
||||
|
||||
+49
-5
@@ -27,6 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
@@ -44,7 +48,9 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
* signatures still came from the old one. [open] is reached only after the broker authorized the
|
||||
* subscription (RELAY consent).
|
||||
*/
|
||||
class NappletLiveSubscriptions {
|
||||
class NappletLiveSubscriptions(
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val liveSubs = ConcurrentHashMap<String, LiveSub>()
|
||||
private val liveSeq = AtomicInteger(0)
|
||||
|
||||
@@ -53,6 +59,20 @@ class NappletLiveSubscriptions {
|
||||
val client: INostrClient,
|
||||
) {
|
||||
val eoseSent = AtomicBoolean(false)
|
||||
val deliveries = Channel<Delivery>(Channel.UNLIMITED)
|
||||
var deliveryJob: Job? = null
|
||||
}
|
||||
|
||||
private sealed interface Delivery {
|
||||
data class RelayEvent(
|
||||
val event: Event,
|
||||
) : Delivery
|
||||
|
||||
data object Eose : Delivery
|
||||
|
||||
data class Closed(
|
||||
val reason: String,
|
||||
) : Delivery
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,6 +97,20 @@ class NappletLiveSubscriptions {
|
||||
// can't collide with the subscription it's replacing.
|
||||
val sub = LiveSub("napplet-$nappletSubId-${liveSeq.incrementAndGet()}", account.client)
|
||||
liveSubs[nappletSubId] = sub
|
||||
sub.deliveryJob =
|
||||
scope.launch {
|
||||
for (delivery in sub.deliveries) {
|
||||
if (liveSubs[nappletSubId] !== sub) break
|
||||
when (delivery) {
|
||||
is Delivery.RelayEvent ->
|
||||
NappletRelayCleartext.forDelivery(delivery.event, account.signer)?.let {
|
||||
push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it))
|
||||
}
|
||||
Delivery.Eose -> push(NappletProtocolJson.encodeRelayEose(nappletSubId))
|
||||
is Delivery.Closed -> push(NappletProtocolJson.encodeRelayClosed(nappletSubId, delivery.reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
@@ -85,7 +119,9 @@ class NappletLiveSubscriptions {
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) = push(NappletProtocolJson.encodeRelayEvent(nappletSubId, event))
|
||||
) {
|
||||
sub.deliveries.trySend(Delivery.RelayEvent(event))
|
||||
}
|
||||
|
||||
// A subscription fans out to several relays; collapse their EOSEs into the single
|
||||
// relay.eose the SDK expects (fired when the first relay finishes its stored events).
|
||||
@@ -93,14 +129,16 @@ class NappletLiveSubscriptions {
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (sub.eoseSent.compareAndSet(false, true)) push(NappletProtocolJson.encodeRelayEose(nappletSubId))
|
||||
if (sub.eoseSent.compareAndSet(false, true)) sub.deliveries.trySend(Delivery.Eose)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
message: String,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) = push(NappletProtocolJson.encodeRelayClosed(nappletSubId, message))
|
||||
) {
|
||||
sub.deliveries.trySend(Delivery.Closed(message))
|
||||
}
|
||||
}
|
||||
|
||||
runCatching { sub.client.subscribe(sub.clientSubId, relays.associateWith { filters }, listener) }
|
||||
@@ -109,12 +147,18 @@ class NappletLiveSubscriptions {
|
||||
/** Stops the live subscription for [nappletSubId], unsubscribing from the client that opened it. */
|
||||
fun close(nappletSubId: String) {
|
||||
val sub = liveSubs.remove(nappletSubId) ?: return
|
||||
sub.deliveries.close()
|
||||
sub.deliveryJob?.cancel()
|
||||
runCatching { sub.client.unsubscribe(sub.clientSubId) }
|
||||
}
|
||||
|
||||
/** Tears down every open subscription (service teardown). */
|
||||
fun closeAll() {
|
||||
liveSubs.values.forEach { sub -> runCatching { sub.client.unsubscribe(sub.clientSubId) } }
|
||||
liveSubs.values.forEach { sub ->
|
||||
sub.deliveries.close()
|
||||
sub.deliveryJob?.cancel()
|
||||
runCatching { sub.client.unsubscribe(sub.clientSubId) }
|
||||
}
|
||||
liveSubs.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip44Encryption.Nip44v2
|
||||
|
||||
/** NAP-RELAY read boundary: encrypted event content is decrypted or withheld, never exposed. */
|
||||
internal object NappletRelayCleartext {
|
||||
suspend fun forDelivery(
|
||||
event: Event,
|
||||
signer: NostrSigner,
|
||||
): Event? = forDelivery(event, signer.pubKey, signer::decrypt)
|
||||
|
||||
internal suspend fun forDelivery(
|
||||
event: Event,
|
||||
userPubKey: HexKey,
|
||||
decrypt: suspend (String, HexKey) -> String,
|
||||
): Event? {
|
||||
if (!isEncrypted(event)) return event
|
||||
|
||||
val peer =
|
||||
when {
|
||||
event.pubKey == userPubKey -> event.recipientPubKey()
|
||||
event.isAddressedTo(userPubKey) -> event.pubKey
|
||||
else -> null
|
||||
} ?: return null
|
||||
val cleartext = runCatching { decrypt(event.content, peer) }.getOrNull() ?: return null
|
||||
|
||||
// NAP-RELAY defines a decrypted read projection. Retain the relay event's identity and
|
||||
// signature fields so callers can still correlate it, while making clear that this object
|
||||
// must never be republished as a signed event after its content projection has changed.
|
||||
return Event(event.id, event.pubKey, event.createdAt, event.kind, event.tags, cleartext, event.sig)
|
||||
}
|
||||
|
||||
internal fun isEncrypted(event: Event): Boolean =
|
||||
event is PrivateDmEvent ||
|
||||
EncryptedInfo.isNIP04(event.content) ||
|
||||
isNip44V2(event.content)
|
||||
|
||||
private fun isNip44V2(content: String): Boolean =
|
||||
content.length >= MIN_NIP44_V2_LENGTH &&
|
||||
runCatching { Nip44v2.EncryptedInfo.decodePayload(content) }.isSuccess
|
||||
|
||||
private fun Event.recipientPubKey(): HexKey? =
|
||||
tags.firstNotNullOfOrNull { tag ->
|
||||
tag.getOrNull(1)?.takeIf { tag.getOrNull(0) == "p" }
|
||||
}
|
||||
|
||||
private fun Event.isAddressedTo(pubKey: HexKey): Boolean = tags.any { tag -> tag.getOrNull(0) == "p" && tag.getOrNull(1) == pubKey }
|
||||
|
||||
private const val MIN_NIP44_V2_LENGTH = 132
|
||||
}
|
||||
+3
-1
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator
|
||||
import com.vitorpamplona.amethyst.napplet.NappletConsentSummary
|
||||
import com.vitorpamplona.amethyst.napplet.NappletNotificationStore
|
||||
import com.vitorpamplona.amethyst.napplet.NappletRelayCleartext
|
||||
import com.vitorpamplona.amethyst.napplet.buildConnectInfo
|
||||
import com.vitorpamplona.amethyst.napplet.buildSignerConsentInfo
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
||||
@@ -265,7 +266,8 @@ class AccountNappletGateways(
|
||||
.distinctBy { it.id }
|
||||
.sortedByDescending { it.createdAt }
|
||||
val limit = filters.mapNotNull { it.limit }.maxOrNull()
|
||||
return limit?.let { merged.take(it) } ?: merged
|
||||
val limited = limit?.let { merged.take(it) } ?: merged
|
||||
return limited.mapNotNull { NappletRelayCleartext.forDelivery(it, account.signer) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+244
-58
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.napplet.gateways
|
||||
|
||||
import android.util.Base64
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletResource
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletResourceResult
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
@@ -39,9 +40,21 @@ import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.sniffContentType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Dns
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.InetAddress
|
||||
import java.net.URLDecoder
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Fetches a resource URL on an applet's behalf — the applet has no direct network
|
||||
@@ -63,41 +76,100 @@ class NappletResourceFetcher(
|
||||
private val account: Account,
|
||||
private val httpClient: (useProxy: Boolean) -> OkHttpClient,
|
||||
) {
|
||||
/** Fetches an https/data/blossom resource for the applet at [coordinate], or null if unsupported/unavailable. */
|
||||
/** Fetches an https/data/blossom/nostr resource and preserves the NAP-RESOURCE error category. */
|
||||
suspend fun fetch(
|
||||
url: String,
|
||||
coordinate: String,
|
||||
): NappletResource? =
|
||||
): NappletResourceResult =
|
||||
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
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build(),
|
||||
).execute()
|
||||
.use { r ->
|
||||
if (!r.isSuccessful) return@withContext null
|
||||
val body = r.body.bytes()
|
||||
val type = r.header("Content-Type") ?: "application/octet-stream"
|
||||
NappletResource(body, type)
|
||||
}
|
||||
}.getOrNull()
|
||||
url.startsWith("nostr:") ->
|
||||
resolveNostr(url)?.let(::success) ?: failure(ERROR_NOT_FOUND, "Nostr resource not found.")
|
||||
url.startsWith("https://") || url.startsWith("blossom:") -> {
|
||||
// Route like the applet's own page: locked napplets stay on Tor. The derived
|
||||
// client removes ambient cookies/auth and validates DNS before every hop.
|
||||
NappletNetworkRegistry.awaitReady()
|
||||
val client = hardenedClient(httpClient(NappletNetworkRegistry.useTor(coordinate)))
|
||||
if (url.startsWith("https://")) fetchHttps(url, client) else fetchBlossom(url, client)
|
||||
}
|
||||
url.startsWith("blossom:") -> fetchBlossom(url, client)
|
||||
url.startsWith("nostr:") -> resolveNostr(url)
|
||||
else -> null
|
||||
else -> failure(ERROR_UNSUPPORTED_SCHEME, "Unsupported resource URL scheme.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun hardenedClient(baseClient: OkHttpClient): OkHttpClient =
|
||||
baseClient
|
||||
.newBuilder()
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.cache(null)
|
||||
.cookieJar(CookieJar.NO_COOKIES)
|
||||
.authenticator(Authenticator.NONE)
|
||||
.proxyAuthenticator(Authenticator.NONE)
|
||||
.callTimeout(FETCH_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.dns(
|
||||
Dns { hostname ->
|
||||
baseClient.dns.lookup(hostname).also { addresses ->
|
||||
if (addresses.isEmpty() || !addresses.all(::isPublicAddress)) {
|
||||
throw BlockedResourceException("Resolved address is not public.")
|
||||
}
|
||||
}
|
||||
},
|
||||
).addNetworkInterceptor { chain ->
|
||||
chain.proceed(
|
||||
chain
|
||||
.request()
|
||||
.newBuilder()
|
||||
.removeHeader("Authorization")
|
||||
.removeHeader("Cookie")
|
||||
.removeHeader("Proxy-Authorization")
|
||||
.build(),
|
||||
)
|
||||
}.build()
|
||||
|
||||
private fun fetchHttps(
|
||||
url: String,
|
||||
client: OkHttpClient,
|
||||
): NappletResourceResult {
|
||||
var current = safeHttpsUrl(url) ?: return failure(ERROR_BLOCKED, "Only credential-free HTTPS URLs are allowed.")
|
||||
repeat(MAX_REDIRECTS + 1) { hop ->
|
||||
try {
|
||||
client
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(current)
|
||||
.get()
|
||||
.build(),
|
||||
).execute()
|
||||
.use { response ->
|
||||
if (response.isRedirect) {
|
||||
if (hop >= MAX_REDIRECTS) return failure(ERROR_BLOCKED, "Redirect limit exceeded.")
|
||||
val location = response.header("Location") ?: return failure(ERROR_NETWORK, "Redirect has no location.")
|
||||
current = safeHttpsUrl(current.resolve(location)) ?: return failure(ERROR_BLOCKED, "Redirect left credential-free HTTPS.")
|
||||
return@repeat
|
||||
}
|
||||
if (response.code == 404) return failure(ERROR_NOT_FOUND)
|
||||
if (!response.isSuccessful) return failure(ERROR_NETWORK, "Upstream returned HTTP ${response.code}.")
|
||||
if (response.body.contentLength() > MAX_RESOURCE_BYTES) return failure(ERROR_TOO_LARGE)
|
||||
val body = readBounded(response.body.byteStream()) ?: return failure(ERROR_TOO_LARGE)
|
||||
return classify(body)
|
||||
}
|
||||
} catch (e: BlockedResourceException) {
|
||||
return failure(ERROR_BLOCKED, e.message)
|
||||
} catch (_: InterruptedIOException) {
|
||||
return failure(ERROR_TIMEOUT)
|
||||
} catch (_: Exception) {
|
||||
return failure(ERROR_NETWORK)
|
||||
}
|
||||
}
|
||||
return failure(ERROR_BLOCKED, "Redirect limit exceeded.")
|
||||
}
|
||||
|
||||
private fun safeHttpsUrl(url: String): HttpUrl? = url.toHttpUrlOrNull()?.takeIf { isSafeHttpsResourceUrl(url) }
|
||||
|
||||
private fun safeHttpsUrl(url: HttpUrl?): HttpUrl? = url?.takeIf { it.scheme == "https" && it.username.isEmpty() && it.password.isEmpty() }
|
||||
|
||||
/**
|
||||
* Resolves a `nostr:` URI (NIP-19) to the referenced event and returns its JSON. An `nembed`
|
||||
* carries the event inline; `note`/`nevent`/`naddr` resolve from the local cache, falling back to
|
||||
@@ -158,62 +230,176 @@ class NappletResourceFetcher(
|
||||
private fun fetchBlossom(
|
||||
url: String,
|
||||
client: OkHttpClient,
|
||||
): NappletResource? {
|
||||
val hash =
|
||||
url
|
||||
.removePrefix("blossom://")
|
||||
.removePrefix("blossom:")
|
||||
.substringBefore('/')
|
||||
.substringBefore('?')
|
||||
.trim()
|
||||
.lowercase()
|
||||
if (!hash.matches(Regex("^[0-9a-f]{64}$"))) return null
|
||||
): NappletResourceResult {
|
||||
if (!url.startsWith(BLOSSOM_SHA256_PREFIX)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.")
|
||||
val hash = url.removePrefix(BLOSSOM_SHA256_PREFIX).lowercase()
|
||||
if (!hash.matches(SHA256)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.")
|
||||
|
||||
val servers =
|
||||
account.blossomServers
|
||||
.getBlossomServersList()
|
||||
?.servers()
|
||||
.orEmpty()
|
||||
var sawHashMismatch = false
|
||||
for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) {
|
||||
val bytes =
|
||||
runCatching {
|
||||
client
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(candidate)
|
||||
.get()
|
||||
.build(),
|
||||
).execute()
|
||||
.use { r ->
|
||||
if (r.isSuccessful) r.body.bytes() else null
|
||||
}
|
||||
}.getOrNull() ?: continue
|
||||
if (StaticSiteResolver.verify(bytes, hash)) {
|
||||
return NappletResource(bytes, sniffContentType(bytes) ?: "application/octet-stream")
|
||||
when (val fetched = fetchHttps(candidate, client)) {
|
||||
is NappletResourceResult.Success -> {
|
||||
if (!StaticSiteResolver.verify(fetched.resource.bytes, hash)) {
|
||||
sawHashMismatch = true
|
||||
continue
|
||||
}
|
||||
return fetched
|
||||
}
|
||||
is NappletResourceResult.Failure -> if (fetched.error == ERROR_BLOCKED) return fetched
|
||||
}
|
||||
}
|
||||
return null
|
||||
if (sawHashMismatch) return failure(ERROR_DECODE_FAILED, "Blossom SHA-256 verification failed.")
|
||||
return failure(ERROR_NOT_FOUND, "No Blossom server returned the verified blob.")
|
||||
}
|
||||
|
||||
/** Parses a `data:[<mediatype>][;base64],<data>` URL into bytes + content type. */
|
||||
private fun decodeDataUrl(url: String): NappletResource? {
|
||||
private fun decodeDataUrl(url: String): NappletResourceResult {
|
||||
val comma = url.indexOf(',')
|
||||
if (comma < 0) return null
|
||||
if (comma < 0) return failure(ERROR_INVALID_REQUEST, "Malformed data URL.")
|
||||
val meta = url.substring("data:".length, comma)
|
||||
val data = url.substring(comma + 1)
|
||||
if (data.length > MAX_DATA_URL_CHARS) return failure(ERROR_TOO_LARGE)
|
||||
val isBase64 = meta.endsWith(";base64")
|
||||
val contentType = meta.removeSuffix(";base64").ifEmpty { "text/plain" }
|
||||
val declaredType =
|
||||
meta
|
||||
.removeSuffix(";base64")
|
||||
.substringBefore(';')
|
||||
.ifEmpty { "text/plain" }
|
||||
.lowercase()
|
||||
val bytes =
|
||||
if (isBase64) {
|
||||
runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull() ?: return null
|
||||
runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull()
|
||||
?: return failure(ERROR_DECODE_FAILED, "Invalid base64 data URL.")
|
||||
} else {
|
||||
URLDecoder.decode(data, "UTF-8").encodeToByteArray()
|
||||
runCatching { URLDecoder.decode(data, "UTF-8").encodeToByteArray() }.getOrNull()
|
||||
?: return failure(ERROR_DECODE_FAILED, "Invalid escaped data URL.")
|
||||
}
|
||||
return NappletResource(bytes, contentType)
|
||||
if (bytes.size > MAX_RESOURCE_BYTES) return failure(ERROR_TOO_LARGE)
|
||||
return classify(bytes, declaredType)
|
||||
}
|
||||
|
||||
private fun classify(
|
||||
bytes: ByteArray,
|
||||
declaredType: String? = null,
|
||||
): NappletResourceResult {
|
||||
if (looksLikeSvg(bytes)) return failure(ERROR_BLOCKED, "Raw SVG is not delivered by this runtime.")
|
||||
val sniffed = sniffContentType(bytes)
|
||||
val type =
|
||||
when {
|
||||
sniffed in ALLOWED_SNIFFED_TYPES -> sniffed
|
||||
declaredType == "application/json" && isJson(bytes) -> "application/json"
|
||||
declaredType == "text/plain" && isPlainText(bytes) -> "text/plain"
|
||||
else -> null
|
||||
} ?: return failure(ERROR_DECODE_FAILED, "Resource MIME is not in the runtime allowlist.")
|
||||
return success(NappletResource(bytes, type))
|
||||
}
|
||||
|
||||
private fun looksLikeSvg(bytes: ByteArray): Boolean {
|
||||
val prefix = bytes.copyOfRange(0, minOf(bytes.size, MIME_PREFIX_BYTES)).decodeToString().lowercase()
|
||||
return prefix.contains("<svg")
|
||||
}
|
||||
|
||||
private fun isJson(bytes: ByteArray): Boolean = runCatching { Json.parseToJsonElement(bytes.decodeToString()) }.isSuccess
|
||||
|
||||
private fun isPlainText(bytes: ByteArray): Boolean =
|
||||
runCatching {
|
||||
Charsets.UTF_8
|
||||
.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(bytes))
|
||||
}.isSuccess && bytes.none { it == 0.toByte() }
|
||||
|
||||
private fun success(resource: NappletResource): NappletResourceResult = NappletResourceResult.Success(resource)
|
||||
|
||||
private fun failure(
|
||||
error: String,
|
||||
message: String? = null,
|
||||
): NappletResourceResult = NappletResourceResult.Failure(error, message)
|
||||
|
||||
private fun readBounded(input: java.io.InputStream): ByteArray? {
|
||||
input.use { source ->
|
||||
val output = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
var total = 0
|
||||
while (true) {
|
||||
val read = source.read(buffer)
|
||||
if (read < 0) break
|
||||
total += read
|
||||
if (total > MAX_RESOURCE_BYTES) return null
|
||||
output.write(buffer, 0, read)
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun isSafeHttpsResourceUrl(url: String): Boolean = url.toHttpUrlOrNull()?.let { it.scheme == "https" && it.username.isEmpty() && it.password.isEmpty() } == true
|
||||
|
||||
internal fun isPublicAddress(address: InetAddress): Boolean {
|
||||
if (address.isAnyLocalAddress || address.isLoopbackAddress || address.isLinkLocalAddress || address.isSiteLocalAddress || address.isMulticastAddress) {
|
||||
return false
|
||||
}
|
||||
val bytes = address.address
|
||||
if (bytes.size == 4) {
|
||||
val first = bytes[0].toInt() and 0xff
|
||||
val second = bytes[1].toInt() and 0xff
|
||||
// Shared address space (100.64/10) and reserved/non-routed ranges Java does not classify.
|
||||
if (first == 0 || first >= 224) return false
|
||||
if (first == 100 && second in 64..127) return false
|
||||
if (first == 192 && second == 0) return false
|
||||
if (first == 198 && second in 18..19) return false
|
||||
if (first == 198 && second == 51 && (bytes[2].toInt() and 0xff) == 100) return false
|
||||
if (first == 203 && second == 0 && (bytes[2].toInt() and 0xff) == 113) return false
|
||||
} else if (bytes.size == 16) {
|
||||
val first = bytes[0].toInt() and 0xff
|
||||
if (first and 0xfe == 0xfc) return false // fc00::/7 unique-local
|
||||
if (
|
||||
first == 0x20 &&
|
||||
(bytes[1].toInt() and 0xff) == 0x01 &&
|
||||
(bytes[2].toInt() and 0xff) == 0x0d &&
|
||||
(bytes[3].toInt() and 0xff) == 0xb8
|
||||
) {
|
||||
return false // 2001:db8::/32 documentation range
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private const val NOSTR_FETCH_TIMEOUT_MS = 8_000L
|
||||
private const val FETCH_TIMEOUT_SECONDS = 30L
|
||||
private const val MAX_REDIRECTS = 5
|
||||
private const val MIME_PREFIX_BYTES = 8 * 1024
|
||||
private const val MAX_DATA_URL_CHARS = 24 * 1024 * 1024
|
||||
private const val BLOSSOM_SHA256_PREFIX = "blossom:sha256:"
|
||||
const val MAX_RESOURCE_BYTES = 10 * 1024 * 1024
|
||||
private const val ERROR_INVALID_REQUEST = "invalid-request"
|
||||
private const val ERROR_NOT_FOUND = "not-found"
|
||||
private const val ERROR_BLOCKED = "blocked-by-policy"
|
||||
private const val ERROR_TIMEOUT = "timeout"
|
||||
private const val ERROR_TOO_LARGE = "too-large"
|
||||
private const val ERROR_UNSUPPORTED_SCHEME = "unsupported-scheme"
|
||||
private const val ERROR_DECODE_FAILED = "decode-failed"
|
||||
private const val ERROR_NETWORK = "network-error"
|
||||
private val SHA256 = Regex("^[0-9a-f]{64}$")
|
||||
private val ALLOWED_SNIFFED_TYPES =
|
||||
setOf(
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"audio/ogg",
|
||||
"video/mp4",
|
||||
)
|
||||
}
|
||||
|
||||
private class BlockedResourceException(
|
||||
message: String,
|
||||
) : java.io.IOException(message)
|
||||
}
|
||||
|
||||
-1
@@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
|
||||
internal fun NappletCapability.symbol(): MaterialSymbol =
|
||||
when (this) {
|
||||
NappletCapability.SHELL -> MaterialSymbols.Tune
|
||||
NappletCapability.IDENTITY -> MaterialSymbols.AccountCircle
|
||||
NappletCapability.KEYS -> MaterialSymbols.Key
|
||||
NappletCapability.RELAY -> MaterialSymbols.Public
|
||||
|
||||
+92
-14
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
@@ -73,11 +74,6 @@ class NappletProtocolJsonTest {
|
||||
assertEquals(NappletRequest.GetPublicKey, NappletProtocolJson.decodeRequest("""{"type":"identity.getPublicKey","id":"1"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesShellSupports() {
|
||||
assertEquals(NappletRequest.ShellSupports("relay"), NappletProtocolJson.decodeRequest("""{"type":"shell.supports","id":"1","domain":"relay"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesPublishFromAnUnsignedTemplateInTheEventField() {
|
||||
// @napplet/shim carries the unsigned template in the `event` field. The shell signs it.
|
||||
@@ -165,13 +161,22 @@ class NappletProtocolJsonTest {
|
||||
assertEquals(NappletRequest.StorageGet("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.get","key":"k"}"""))
|
||||
assertEquals(NappletRequest.StorageSet("k", "v"), NappletProtocolJson.decodeRequest("""{"type":"storage.set","key":"k","value":"v"}"""))
|
||||
assertEquals(NappletRequest.StorageRemove("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.remove","key":"k"}"""))
|
||||
assertEquals(NappletRequest.StorageKeys, NappletProtocolJson.decodeRequest("""{"type":"storage.keys"}"""))
|
||||
assertEquals(NappletRequest.StorageKeys(), NappletProtocolJson.decodeRequest("""{"type":"storage.keys"}"""))
|
||||
assertEquals(
|
||||
NappletRequest.StorageGet("k", NappletStorageScope.INSTANCE),
|
||||
NappletProtocolJson.decodeRequest("""{"type":"storage.get","key":"k","scope":"instance"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesValueResourceUpload() {
|
||||
assertEquals(NappletRequest.PayInvoice("lnbc1"), NappletProtocolJson.decodeRequest("""{"type":"value.payInvoice","invoice":"lnbc1"}"""))
|
||||
assertEquals(NappletRequest.ResourceInfo, NappletProtocolJson.decodeRequest("""{"type":"resource.info"}"""))
|
||||
assertEquals(NappletRequest.ResourceBytes("https://x"), NappletProtocolJson.decodeRequest("""{"type":"resource.bytes","url":"https://x"}"""))
|
||||
assertEquals(
|
||||
NappletRequest.ResourceBytesMany(listOf("https://x", "data:text/plain,hi")),
|
||||
NappletProtocolJson.decodeRequest("""{"type":"resource.bytesMany","urls":["https://x","data:text/plain,hi"]}"""),
|
||||
)
|
||||
// "SGk=" is base64 for "Hi"; shell.html inlines the request Blob as request.dataBase64.
|
||||
val up = NappletProtocolJson.decodeRequest("""{"type":"upload.upload","request":{"dataBase64":"SGk=","mimeType":"text/plain","filename":"a.txt"}}""") as NappletRequest.UploadBlob
|
||||
assertEquals("text/plain", up.contentType)
|
||||
@@ -184,6 +189,7 @@ class NappletProtocolJsonTest {
|
||||
assertNull(NappletProtocolJson.decodeRequest("""{"type":"inc.emit","id":"1"}"""))
|
||||
// keys.signEvent is not a real domain method (keys = keyboard actions, not signing).
|
||||
assertNull(NappletProtocolJson.decodeRequest("""{"type":"keys.signEvent","id":"1"}"""))
|
||||
assertNull(NappletProtocolJson.decodeRequest("""{"type":"identity.futureMethod","id":"1"}"""))
|
||||
assertNull(NappletProtocolJson.decodeRequest("""{"foo":"bar"}"""))
|
||||
}
|
||||
|
||||
@@ -211,7 +217,9 @@ class NappletProtocolJsonTest {
|
||||
assertEquals("s1", ev["subId"]?.jsonPrimitive?.content)
|
||||
assertEquals(
|
||||
"a".repeat(64),
|
||||
ev["event"]
|
||||
ev["result"]
|
||||
?.jsonObject
|
||||
?.get("event")
|
||||
?.jsonObject
|
||||
?.get("id")
|
||||
?.jsonPrimitive
|
||||
@@ -233,13 +241,6 @@ class NappletProtocolJsonTest {
|
||||
assertEquals("pk", o["pubkey"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesSupported() {
|
||||
val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse("shell.supports", NappletResponse.Supported(true))).jsonObject
|
||||
assertEquals("shell.supports.result", o["type"]?.jsonPrimitive?.content)
|
||||
assertTrue(o["supported"]!!.jsonPrimitive.boolean)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesPublishedEventAndEvents() {
|
||||
// relay.publish resolves to the signed event (matching upstream NostrEvent return).
|
||||
@@ -257,6 +258,18 @@ class NappletProtocolJsonTest {
|
||||
|
||||
val events = json.parseToJsonElement(NappletProtocolJson.encodeResponse("relay.query", NappletResponse.Events(listOf(sampleEvent())))).jsonObject
|
||||
assertEquals(1, events["events"]?.jsonArray?.size)
|
||||
assertEquals(
|
||||
"a".repeat(64),
|
||||
events["events"]
|
||||
?.jsonArray
|
||||
?.first()
|
||||
?.jsonObject
|
||||
?.get("event")
|
||||
?.jsonObject
|
||||
?.get("id")
|
||||
?.jsonPrimitive
|
||||
?.content,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -269,6 +282,71 @@ class NappletProtocolJsonTest {
|
||||
assertEquals(2, keys["keys"]?.jsonArray?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesResourceInfoBulkItemsAndTypedErrors() {
|
||||
val info =
|
||||
json
|
||||
.parseToJsonElement(
|
||||
NappletProtocolJson.encodeResponse(
|
||||
"resource.info",
|
||||
NappletResponse.ResourceInfo(listOf("https"), 10L * 1024L * 1024L, 16),
|
||||
),
|
||||
).jsonObject
|
||||
assertEquals(
|
||||
"https",
|
||||
info["info"]
|
||||
?.jsonObject
|
||||
?.get("schemes")
|
||||
?.jsonArray
|
||||
?.first()
|
||||
?.jsonObject
|
||||
?.get("scheme")
|
||||
?.jsonPrimitive
|
||||
?.content,
|
||||
)
|
||||
|
||||
val items =
|
||||
json
|
||||
.parseToJsonElement(
|
||||
NappletProtocolJson.encodeResponse(
|
||||
"resource.bytesMany",
|
||||
NappletResponse.ResourceItems(
|
||||
listOf(
|
||||
NappletResponse.ResourceItem("https://x", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain")),
|
||||
NappletResponse.ResourceItem("https://y", error = "not-found"),
|
||||
),
|
||||
),
|
||||
),
|
||||
).jsonObject["items"]
|
||||
?.jsonArray
|
||||
assertEquals(
|
||||
"SGk=",
|
||||
items
|
||||
?.first()
|
||||
?.jsonObject
|
||||
?.get("bytes")
|
||||
?.jsonPrimitive
|
||||
?.content,
|
||||
)
|
||||
assertEquals(
|
||||
"not-found",
|
||||
items
|
||||
?.get(1)
|
||||
?.jsonObject
|
||||
?.get("error")
|
||||
?.jsonPrimitive
|
||||
?.content,
|
||||
)
|
||||
|
||||
val failure =
|
||||
json
|
||||
.parseToJsonElement(
|
||||
NappletProtocolJson.encodeResponse("resource.bytes", NappletResponse.ResourceFailure("blocked-by-policy", "private target")),
|
||||
).jsonObject
|
||||
assertEquals("resource.bytes.error", failure["type"]?.jsonPrimitive?.content)
|
||||
assertEquals("blocked-by-policy", failure["error"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesBytesAsBase64WithMime() {
|
||||
val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse("resource.bytes", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain"))).jsonObject
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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.quartz.nip01Core.core.Event
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
class NappletRelayCleartextTest {
|
||||
@Test
|
||||
fun plaintextPassesThroughWithoutDecrypting() =
|
||||
runTest {
|
||||
val event = event(content = "hello")
|
||||
val result =
|
||||
NappletRelayCleartext.forDelivery(event, USER) { _, _ ->
|
||||
error("plaintext must not be decrypted")
|
||||
}
|
||||
|
||||
assertSame(event, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun inboundNip04IsProjectedAsCleartext() =
|
||||
runTest {
|
||||
val event = event(content = NIP04, tags = arrayOf(arrayOf("p", USER)))
|
||||
val result =
|
||||
NappletRelayCleartext.forDelivery(event, USER) { ciphertext, peer ->
|
||||
assertEquals(NIP04, ciphertext)
|
||||
assertEquals(AUTHOR, peer)
|
||||
"secret"
|
||||
}
|
||||
|
||||
assertEquals("secret", result?.content)
|
||||
assertEquals(event.id, result?.id)
|
||||
assertEquals(event.sig, result?.sig)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outboundEncryptedEventUsesItsRecipientAsPeer() =
|
||||
runTest {
|
||||
val event = event(pubKey = USER, content = NIP04, tags = arrayOf(arrayOf("p", RECIPIENT)))
|
||||
val result =
|
||||
NappletRelayCleartext.forDelivery(event, USER) { _, peer ->
|
||||
assertEquals(RECIPIENT, peer)
|
||||
"sent secret"
|
||||
}
|
||||
|
||||
assertEquals("sent secret", result?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encryptedEventForAnotherUserIsWithheld() =
|
||||
runTest {
|
||||
val event = event(content = NIP04, tags = arrayOf(arrayOf("p", RECIPIENT)))
|
||||
val result =
|
||||
NappletRelayCleartext.forDelivery(event, USER) { _, _ ->
|
||||
error("unrelated ciphertext must not be offered to the signer")
|
||||
}
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decryptionFailureWithholdsCiphertext() =
|
||||
runTest {
|
||||
val event = event(content = NIP04, tags = arrayOf(arrayOf("p", USER)))
|
||||
val result =
|
||||
NappletRelayCleartext.forDelivery(event, USER) { _, _ ->
|
||||
error("signer refused")
|
||||
}
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
private fun event(
|
||||
pubKey: String = AUTHOR,
|
||||
content: String,
|
||||
tags: Array<Array<String>> = emptyArray(),
|
||||
) = Event("id", pubKey, 1L, 4, tags, content, "sig")
|
||||
|
||||
companion object {
|
||||
private const val USER = "user"
|
||||
private const val AUTHOR = "author"
|
||||
private const val RECIPIENT = "recipient"
|
||||
private const val NIP04 = "ciphertext-that-is-long-enough?iv=123456789012345678901234"
|
||||
}
|
||||
}
|
||||
+27
-26
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
@@ -114,17 +115,31 @@ class NappletSdkConformanceTest {
|
||||
// RelayQueryResultMessage: { type:'relay.query.result', id, events, error? }
|
||||
val o = result("relay.query", NappletResponse.Events(listOf(sampleEvent())))
|
||||
assertEquals(1, o["events"]?.jsonArray?.size)
|
||||
assertEquals(
|
||||
"a".repeat(64),
|
||||
o["events"]
|
||||
?.jsonArray
|
||||
?.first()
|
||||
?.jsonObject
|
||||
?.get("event")
|
||||
?.jsonObject
|
||||
?.get("id")
|
||||
?.jsonPrimitive
|
||||
?.content,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayEventAndEosePushesMatchTheSdk() {
|
||||
// RelayEventMessage (PUSH): { type:'relay.event', subId, event }
|
||||
// RelayEventMessage (PUSH): { type:'relay.event', subId, result:{event, sidecar?} }
|
||||
val ev = json.parseToJsonElement(NappletProtocolJson.encodeRelayEvent("s1", sampleEvent())).jsonObject
|
||||
assertEquals("relay.event", ev["type"]?.jsonPrimitive?.content)
|
||||
assertEquals("s1", ev["subId"]?.jsonPrimitive?.content)
|
||||
assertEquals(
|
||||
"a".repeat(64),
|
||||
ev["event"]
|
||||
ev["result"]
|
||||
?.jsonObject
|
||||
?.get("event")
|
||||
?.jsonObject
|
||||
?.get("id")
|
||||
?.jsonPrimitive
|
||||
@@ -175,7 +190,11 @@ class NappletSdkConformanceTest {
|
||||
assertEquals(NappletRequest.StorageGet("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.get","id":"1","key":"k"}"""))
|
||||
assertEquals(NappletRequest.StorageSet("k", "v"), NappletProtocolJson.decodeRequest("""{"type":"storage.set","id":"1","key":"k","value":"v"}"""))
|
||||
assertEquals(NappletRequest.StorageRemove("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.remove","id":"1","key":"k"}"""))
|
||||
assertEquals(NappletRequest.StorageKeys, NappletProtocolJson.decodeRequest("""{"type":"storage.keys","id":"1"}"""))
|
||||
assertEquals(NappletRequest.StorageKeys(), NappletProtocolJson.decodeRequest("""{"type":"storage.keys","id":"1"}"""))
|
||||
assertEquals(
|
||||
NappletRequest.StorageGet("k", NappletStorageScope.INSTANCE),
|
||||
NappletProtocolJson.decodeRequest("""{"type":"storage.get","id":"1","key":"k","scope":"instance"}"""),
|
||||
)
|
||||
|
||||
// StorageGetResultMessage.value, StorageKeysResultMessage.keys
|
||||
assertTrue(result("storage.get", NappletResponse.StorageValue("v")).containsKey("value"))
|
||||
@@ -186,7 +205,12 @@ class NappletSdkConformanceTest {
|
||||
|
||||
@Test
|
||||
fun resourceBytesRequestAndResultMatch() {
|
||||
assertEquals(NappletRequest.ResourceInfo, NappletProtocolJson.decodeRequest("""{"type":"resource.info","id":"0"}"""))
|
||||
assertEquals(NappletRequest.ResourceBytes("https://x"), NappletProtocolJson.decodeRequest("""{"type":"resource.bytes","id":"1","url":"https://x"}"""))
|
||||
assertEquals(
|
||||
NappletRequest.ResourceBytesMany(listOf("https://x", "data:text/plain,hi")),
|
||||
NappletProtocolJson.decodeRequest("""{"type":"resource.bytesMany","id":"2","urls":["https://x","data:text/plain,hi"]}"""),
|
||||
)
|
||||
// The host emits base64 bytes + mime; shell.html rebuilds the Blob the SDK expects.
|
||||
val o = result("resource.bytes", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain"))
|
||||
assertEquals("SGk=", o["bytes"]?.jsonPrimitive?.content)
|
||||
@@ -203,29 +227,6 @@ class NappletSdkConformanceTest {
|
||||
assertTrue(result("relay.query", NappletResponse.Failed("boom")).containsKey("error"))
|
||||
}
|
||||
|
||||
// ---------- shell handshake (ShellReadyMessage / ShellInitMessage) ----------
|
||||
|
||||
@Test
|
||||
fun shellInitAdvertisesTheCapabilityEnvironment() {
|
||||
// shell.ready is answered by the host (not the codec) with this shell.init env, which the
|
||||
// SDK caches and answers shell.supports() from locally. ShellInitMessage:
|
||||
// { type:'shell.init', capabilities:{ domains, protocols }, services }.
|
||||
val o = json.parseToJsonElement(NappletProtocolJson.encodeShellInit(listOf("shell", "relay"), listOf("shell", "relay"))).jsonObject
|
||||
assertEquals("shell.init", o["type"]?.jsonPrimitive?.content)
|
||||
assertEquals(
|
||||
2,
|
||||
o["capabilities"]
|
||||
?.jsonObject
|
||||
?.get("domains")
|
||||
?.jsonArray
|
||||
?.size,
|
||||
)
|
||||
assertTrue(o["capabilities"]?.jsonObject?.containsKey("protocols") == true)
|
||||
assertEquals(2, o["services"]?.jsonArray?.size)
|
||||
// shell.ready stays a host-layer message — the codec doesn't treat it as a broker request.
|
||||
assertNull(NappletProtocolJson.decodeRequest("""{"type":"shell.ready"}"""))
|
||||
}
|
||||
|
||||
// ---------- keys (keyboard/command actions) ----------
|
||||
|
||||
@Test
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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.gateways
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.net.InetAddress
|
||||
|
||||
class NappletResourceFetcherPolicyTest {
|
||||
@Test
|
||||
fun blocksPrivateSpecialAndLocalAddresses() {
|
||||
val blocked =
|
||||
listOf(
|
||||
"0.0.0.0",
|
||||
"10.0.0.1",
|
||||
"100.64.0.1",
|
||||
"127.0.0.1",
|
||||
"169.254.169.254",
|
||||
"172.16.0.1",
|
||||
"192.0.0.1",
|
||||
"192.0.2.1",
|
||||
"192.168.1.1",
|
||||
"198.18.0.1",
|
||||
"198.51.100.1",
|
||||
"203.0.113.1",
|
||||
"224.0.0.1",
|
||||
"::1",
|
||||
"2001:db8::1",
|
||||
"fc00::1",
|
||||
"fe80::1",
|
||||
)
|
||||
|
||||
blocked.forEach {
|
||||
assertFalse(it, NappletResourceFetcher.isPublicAddress(InetAddress.getByName(it)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun permitsPublicAddresses() {
|
||||
assertTrue(NappletResourceFetcher.isPublicAddress(InetAddress.getByName("1.1.1.1")))
|
||||
assertTrue(NappletResourceFetcher.isPublicAddress(InetAddress.getByName("2606:4700:4700::1111")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsOnlyCredentialFreeHttpsUrls() {
|
||||
assertTrue(NappletResourceFetcher.isSafeHttpsResourceUrl("https://example.com/a"))
|
||||
assertFalse(NappletResourceFetcher.isSafeHttpsResourceUrl("http://example.com/a"))
|
||||
assertFalse(NappletResourceFetcher.isSafeHttpsResourceUrl("https://user:secret@example.com/a"))
|
||||
assertFalse(NappletResourceFetcher.isSafeHttpsResourceUrl("file:///etc/passwd"))
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Trusted napplet shell page (served on the shell origin https://napplet.local at /__shell__).
|
||||
It hosts the untrusted applet in a sandboxed iframe pointed at the applet's OWN per-applet origin
|
||||
(a distinct napplet.local subdomain, injected as __APP_ORIGIN__) and relays capability messages
|
||||
between the applet (window.postMessage) and the native bridge (__nappletBridge), which is
|
||||
origin-restricted to this shell page only. The applet is cross-origin to the shell, so it can never
|
||||
reach the bridge or read the shell DOM. The iframe carries allow-same-origin so the applet has real,
|
||||
isolated storage on its own origin — which is safe precisely because that origin is never the
|
||||
shell's: the applet is same-origin only with itself.
|
||||
It hosts untrusted content and relays capability messages between the child (window.postMessage)
|
||||
and the origin-restricted native bridge (__nappletBridge). For NIP-5D the child receives verified,
|
||||
prelude-injected bytes through srcdoc and has only sandbox="allow-scripts" (opaque origin). NIP-5A
|
||||
websites retain Amethyst's separate-origin website posture and are navigated normally.
|
||||
-->
|
||||
<html>
|
||||
<head>
|
||||
@@ -19,7 +16,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe id="app" sandbox="allow-scripts allow-same-origin" referrerpolicy="no-referrer"></iframe>
|
||||
<iframe id="app" sandbox="__APP_SANDBOX__" referrerpolicy="no-referrer"></iframe>
|
||||
<script>
|
||||
(function () {
|
||||
var iframe = document.getElementById('app');
|
||||
@@ -70,11 +67,22 @@
|
||||
delete msg.bytes;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (msg && msg.type === 'resource.bytesMany.result' && Array.isArray(msg.items)) {
|
||||
msg.items.forEach(function (item) {
|
||||
if (!item || !item.ok || typeof item.bytes !== 'string') return;
|
||||
try {
|
||||
var bin = atob(item.bytes), u = new Uint8Array(bin.length);
|
||||
for (var i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
||||
item.blob = new Blob([u], { type: item.mime || '' });
|
||||
delete item.bytes;
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
iframe.contentWindow.postMessage(msg, '*');
|
||||
};
|
||||
}
|
||||
|
||||
iframe.src = '__APP_ORIGIN__/';
|
||||
__APP_BOOTSTRAP__
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
// Subscription pushes are keyed by subId, not a request id.
|
||||
if (msg.type === 'relay.event' || msg.type === 'relay.eose' || msg.type === 'relay.closed') {
|
||||
var sub = subs[msg.subId]; if (!sub) return;
|
||||
if (msg.type === 'relay.event') { if (sub.onEvent) sub.onEvent(msg.event); }
|
||||
if (msg.type === 'relay.event') { if (sub.onEvent) sub.onEvent(msg.result); }
|
||||
else if (msg.type === 'relay.eose') { if (sub.onEose) sub.onEose(); }
|
||||
else { delete subs[msg.subId]; if (sub.onClosed) sub.onClosed(msg.reason); }
|
||||
return;
|
||||
@@ -91,7 +91,7 @@
|
||||
// identity.changed push: the active user's key changed (account switch / connect / disconnect).
|
||||
if (msg.type === 'identity.changed') { identityHandlers.slice().forEach(function(h){ try { h(msg.pubkey); } catch (_) {} }); return; }
|
||||
if (!msg.id) return;
|
||||
var p = pending[msg.id]; if (!p) return; delete pending[msg.id];
|
||||
var p = pending[msg.id]; if (!p) return; delete pending[msg.id]; if (p.cleanup) p.cleanup();
|
||||
if (msg.ok) p.resolve(msg);
|
||||
else { var err = new Error(msg.reason || msg.operation || msg.error || 'napplet error'); err.napplet = msg; p.reject(err); }
|
||||
}
|
||||
@@ -101,16 +101,31 @@
|
||||
window.addEventListener('message', function(e){ if (e.source !== parent) return; onIncoming(e.data); });
|
||||
}
|
||||
function field(promise, name){ return promise.then(function(m){ return m[name]; }); }
|
||||
function normFilters(filters){ return Array.isArray(filters) ? { filters: filters } : { filter: filters || {} }; }
|
||||
function resourceCall(type, fields, opts){
|
||||
var signal = opts && opts.signal;
|
||||
if (signal && signal.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError'));
|
||||
return new Promise(function(resolve, reject){
|
||||
var env = { type: type }; for (var k in fields) env[k] = fields[k];
|
||||
var id = send(env), onAbort;
|
||||
var cleanup = function(){ if (signal && onAbort) signal.removeEventListener('abort', onAbort); };
|
||||
pending[id] = { resolve: resolve, reject: reject, cleanup: cleanup };
|
||||
if (signal) {
|
||||
onAbort = function(){
|
||||
if (!pending[id]) return;
|
||||
delete pending[id]; cleanup();
|
||||
post('resource.cancel', { id: id });
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
function normFilters(filters){ return { filters: Array.isArray(filters) ? filters : [filters || {}] }; }
|
||||
function bytesToB64(bytes){ var u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); var s=''; for (var i=0;i<u.length;i++) s+=String.fromCharCode(u[i]); return btoa(s); }
|
||||
var napplet = {
|
||||
shell: {
|
||||
// supports() is synchronous in @napplet/shim; we expose a sync proxy backed by an async check.
|
||||
supports: function(domain, protocol){ return field(call('shell.supports', { domain: domain, protocol: protocol }), 'supported'); },
|
||||
ready: function(){ return Promise.resolve({}); },
|
||||
onReady: function(cb){ if (typeof cb === 'function') cb({}); return { close: function(){} }; },
|
||||
services: []
|
||||
},
|
||||
// Build the methods Amethyst actually implements, then project only the explicit domains the
|
||||
// trusted host authorized for this launch. Domain-object presence is the NIP-5D availability
|
||||
// signal; there is deliberately no legacy window.napplet.shell capability probe.
|
||||
var available = {
|
||||
identity: {
|
||||
getPublicKey: function(){ return field(call('identity.getPublicKey'), 'pubkey'); },
|
||||
getProfile: function(){ return field(call('identity.getProfile'), 'profile'); },
|
||||
@@ -121,15 +136,12 @@
|
||||
getList: function(listType){ return field(call('identity.getList', { listType: listType }), 'entries'); },
|
||||
getZaps: function(){ return field(call('identity.getZaps'), 'zaps'); },
|
||||
getBadges: function(){ return field(call('identity.getBadges'), 'badges'); },
|
||||
// onChanged: the shell pushes identity.changed when the active user's key changes. The first
|
||||
// handler opens the watch (identity.watch); closing the last one stops it (identity.unwatch).
|
||||
// The runtime owns identity.changed delivery; handlers are entirely local to the shim.
|
||||
onChanged: function(handler){
|
||||
if (typeof handler !== 'function') return { close: function(){} };
|
||||
identityHandlers.push(handler);
|
||||
if (identityHandlers.length === 1) post('identity.watch');
|
||||
return { close: function(){
|
||||
var i = identityHandlers.indexOf(handler); if (i >= 0) identityHandlers.splice(i, 1);
|
||||
if (identityHandlers.length === 0) post('identity.unwatch');
|
||||
} };
|
||||
}
|
||||
},
|
||||
@@ -152,6 +164,7 @@
|
||||
var subId = 's' + (seq++);
|
||||
subs[subId] = { onEvent: onEvent, onEose: onEose };
|
||||
var env = normFilters(filters); env.subId = subId;
|
||||
if (options && options.relay) env.relay = options.relay;
|
||||
post('relay.subscribe', env);
|
||||
return { close: function(){ delete subs[subId]; post('relay.close', { subId: subId }); } };
|
||||
}
|
||||
@@ -161,23 +174,49 @@
|
||||
getItem: function(key){ return field(call('storage.get', { key: key }), 'value'); },
|
||||
setItem: function(key, value){ return call('storage.set', { key: key, value: value }).then(function(){}); },
|
||||
removeItem: function(key){ return call('storage.remove', { key: key }).then(function(){}); },
|
||||
keys: function(){ return field(call('storage.keys'), 'keys'); }
|
||||
keys: function(){ return field(call('storage.keys'), 'keys'); },
|
||||
instance: {
|
||||
getItem: function(key){ return field(call('storage.get', { key: key, scope: 'instance' }), 'value'); },
|
||||
setItem: function(key, value){ return call('storage.set', { key: key, value: value, scope: 'instance' }).then(function(){}); },
|
||||
removeItem: function(key){ return call('storage.remove', { key: key, scope: 'instance' }).then(function(){}); },
|
||||
keys: function(){ return field(call('storage.keys', { scope: 'instance' }), 'keys'); }
|
||||
}
|
||||
},
|
||||
// value.payInvoice is an Amethyst-specific extension (not part of @napplet/shim).
|
||||
value: {
|
||||
payInvoice: function(invoice){ return field(call('value.payInvoice', { invoice: invoice }), 'preimage'); }
|
||||
},
|
||||
resource: {
|
||||
// The shell rebuilds the Blob from the host's base64 before this resolves.
|
||||
bytes: function(url){ return field(call('resource.bytes', { url: url }), 'blob'); },
|
||||
bytesAsObjectURL: function(url){ return field(call('resource.bytes', { url: url }), 'blob').then(function(blob){ return URL.createObjectURL(blob); }); }
|
||||
info: function(){ return field(call('resource.info'), 'info'); },
|
||||
// The shell rebuilds Blobs from the host's base64 before these resolve.
|
||||
bytes: function(url, opts){ return field(resourceCall('resource.bytes', { url: url }, opts), 'blob'); },
|
||||
bytesMany: function(urls, opts){ return field(resourceCall('resource.bytesMany', { urls: Array.from(urls || []) }, opts), 'items'); },
|
||||
bytesAsObjectURL: function(url){
|
||||
var objectUrl = '', revoked = false;
|
||||
var handle = { url: '', revoke: function(){ if (revoked) return; revoked = true; if (objectUrl) URL.revokeObjectURL(objectUrl); } };
|
||||
var ready = available.resource.bytes(url).then(function(blob){
|
||||
if (revoked) return;
|
||||
objectUrl = URL.createObjectURL(blob); handle.url = objectUrl; return objectUrl;
|
||||
});
|
||||
Object.defineProperty(handle, 'ready', { value: ready, enumerable: false });
|
||||
return handle;
|
||||
}
|
||||
},
|
||||
upload: {
|
||||
// Sends the SDK's upload.upload; we inline the bytes as base64 (shell.html does the same for
|
||||
// a Blob from a stock napplet). Resolves to the uploaded URL.
|
||||
blob: function(bytes, contentType){ return field(call('upload.upload', { request: { dataBase64: bytesToB64(bytes), mimeType: contentType } }), 'url'); }
|
||||
},
|
||||
theme: {
|
||||
get: function(){ return field(call('theme.get'), 'theme'); }
|
||||
}
|
||||
};
|
||||
var requested = [];
|
||||
try { if (Array.isArray(window.__nappletDomains)) requested = window.__nappletDomains; } catch (_) {}
|
||||
var napplet = {};
|
||||
requested.forEach(function(domain){
|
||||
if (typeof domain === 'string' && Object.prototype.hasOwnProperty.call(available, domain)) napplet[domain] = available[domain];
|
||||
});
|
||||
window.napplet = Object.freeze(napplet);
|
||||
|
||||
// ---- IME agent (in-app browser only) -------------------------------------------------------
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.commons.napplet
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
|
||||
|
||||
/** Pure NIP-5D artifact checks shared by every launch surface. */
|
||||
object NappletArtifactPolicy {
|
||||
/** Returns the runtime-computed artifact identity, or null when the manifest must not execute. */
|
||||
fun verifiedAggregateHash(
|
||||
paths: List<PathTag>,
|
||||
declaredAggregateHash: HexKey?,
|
||||
): HexKey? {
|
||||
val entry = paths.singleOrNull() ?: return null
|
||||
if (entry.path != "/index.html" || !SHA256.matches(entry.hash)) return null
|
||||
val computed = SiteAggregateHash.compute(paths)
|
||||
if (declaredAggregateHash != null && !declaredAggregateHash.equals(computed, ignoreCase = true)) return null
|
||||
return computed
|
||||
}
|
||||
|
||||
private val SHA256 = Regex("^[0-9a-fA-F]{64}$")
|
||||
}
|
||||
+48
-26
@@ -122,12 +122,6 @@ class NappletBroker(
|
||||
): NappletResponse {
|
||||
val capability = request.capability
|
||||
|
||||
// shell.supports is capability negotiation: always answerable, no declaration/consent.
|
||||
if (request is NappletRequest.ShellSupports) {
|
||||
val cap = NappletCapability.fromNapDomain(request.domain)
|
||||
return NappletResponse.Supported(cap != null && cap in declared)
|
||||
}
|
||||
|
||||
if (capability !in declared) {
|
||||
return NappletResponse.Denied(capability, "This napplet did not declare the '${capability.name.lowercase()}' capability.")
|
||||
}
|
||||
@@ -149,7 +143,7 @@ class NappletBroker(
|
||||
// Keyboard/command action registration is a shell-mediated UI affordance, not key
|
||||
// access — declared is enough; it never prompts.
|
||||
request is NappletRequest.RegisterAction || request is NappletRequest.UnregisterAction -> true
|
||||
// Cosmetic/negotiation capabilities (theme) never prompt.
|
||||
// Cosmetic capabilities (theme) never prompt.
|
||||
!capability.requiresConsent -> true
|
||||
// A standing allow short-circuits, except for per-use capabilities (e.g. payments).
|
||||
ledger.decide(identity, capability) == PermissionDecision.ALLOW && !capability.requiresPerUseConsent -> true
|
||||
@@ -219,9 +213,6 @@ class NappletBroker(
|
||||
request: NappletRequest,
|
||||
): NappletResponse =
|
||||
when (request) {
|
||||
// Negotiation is resolved in handle(); execute() is never reached for it.
|
||||
is NappletRequest.ShellSupports -> NappletResponse.Supported(true)
|
||||
|
||||
is NappletRequest.GetPublicKey -> NappletResponse.PublicKey(signer.pubKey)
|
||||
|
||||
is NappletRequest.ThemeGet -> {
|
||||
@@ -267,24 +258,24 @@ class NappletBroker(
|
||||
|
||||
is NappletRequest.StorageGet -> {
|
||||
val store = storage ?: return NappletResponse.Unsupported("storage.getItem")
|
||||
NappletResponse.StorageValue(store.get(identity.coordinate, request.key))
|
||||
NappletResponse.StorageValue(store.get(storageCoordinate(identity, request.scope), request.key))
|
||||
}
|
||||
|
||||
is NappletRequest.StorageSet -> {
|
||||
val store = storage ?: return NappletResponse.Unsupported("storage.setItem")
|
||||
store.set(identity.coordinate, request.key, request.value)
|
||||
store.set(storageCoordinate(identity, request.scope), request.key, request.value)
|
||||
NappletResponse.Done
|
||||
}
|
||||
|
||||
is NappletRequest.StorageRemove -> {
|
||||
val store = storage ?: return NappletResponse.Unsupported("storage.removeItem")
|
||||
store.remove(identity.coordinate, request.key)
|
||||
store.remove(storageCoordinate(identity, request.scope), request.key)
|
||||
NappletResponse.Done
|
||||
}
|
||||
|
||||
is NappletRequest.StorageKeys -> {
|
||||
val store = storage ?: return NappletResponse.Unsupported("storage.keys")
|
||||
NappletResponse.Strings(store.keys(identity.coordinate))
|
||||
NappletResponse.Strings(store.keys(storageCoordinate(identity, request.scope)))
|
||||
}
|
||||
|
||||
is NappletRequest.NotifyCreate -> {
|
||||
@@ -316,8 +307,38 @@ class NappletBroker(
|
||||
|
||||
is NappletRequest.ResourceBytes -> {
|
||||
val gateway = resource ?: return NappletResponse.Unsupported("resource.bytes")
|
||||
val fetched = gateway.fetch(request.url, identity.coordinate) ?: return NappletResponse.Failed("Could not fetch the resource.")
|
||||
NappletResponse.Bytes(fetched.bytes, fetched.contentType)
|
||||
when (val fetched = gateway.fetch(request.url, identity.coordinate)) {
|
||||
is NappletResourceResult.Success -> NappletResponse.Bytes(fetched.resource.bytes, fetched.resource.contentType)
|
||||
is NappletResourceResult.Failure -> NappletResponse.ResourceFailure(fetched.error, fetched.message)
|
||||
}
|
||||
}
|
||||
|
||||
is NappletRequest.ResourceInfo -> {
|
||||
resource ?: return NappletResponse.Unsupported("resource.info")
|
||||
NappletResponse.ResourceInfo(
|
||||
schemes = listOf("data", "https", "blossom", "nostr"),
|
||||
maxBytes = RESOURCE_MAX_BYTES,
|
||||
maxUrls = RESOURCE_MAX_URLS,
|
||||
)
|
||||
}
|
||||
|
||||
is NappletRequest.ResourceBytesMany -> {
|
||||
val gateway = resource ?: return NappletResponse.Unsupported("resource.bytesMany")
|
||||
if (request.urls.isEmpty()) return NappletResponse.ResourceFailure("invalid-request", "Resource URL list is empty.")
|
||||
if (request.urls.size > RESOURCE_MAX_URLS) return NappletResponse.ResourceFailure("too-large", "Resource URL limit exceeded.")
|
||||
NappletResponse.ResourceItems(
|
||||
request.urls.map { url ->
|
||||
when (val fetched = gateway.fetch(url, identity.coordinate)) {
|
||||
is NappletResourceResult.Success ->
|
||||
NappletResponse.ResourceItem(
|
||||
url = url,
|
||||
resource = NappletResponse.Bytes(fetched.resource.bytes, fetched.resource.contentType),
|
||||
)
|
||||
is NappletResourceResult.Failure ->
|
||||
NappletResponse.ResourceItem(url = url, error = fetched.error, message = fetched.message)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is NappletRequest.UploadBlob -> {
|
||||
@@ -353,6 +374,15 @@ class NappletBroker(
|
||||
tags + arrayOf(arrayOf("p", recipient))
|
||||
}
|
||||
|
||||
private fun storageCoordinate(
|
||||
identity: NappletIdentity,
|
||||
scope: com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope,
|
||||
): String =
|
||||
when (scope) {
|
||||
com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope.SHARED -> identity.storageCoordinate
|
||||
com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope.INSTANCE -> identity.instanceStorageCoordinate
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the first-connect "Connect to Nostr" dialog if no signer policy exists yet.
|
||||
* On success, stores the chosen policy and bulk-grants all declared non-payment capabilities.
|
||||
@@ -505,16 +535,6 @@ class NappletBroker(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [capability] carries a standing denial for [identity]. Push-subscription edge ops
|
||||
* (identity.watch and friends) short-circuit before [handle], so they have to apply the same
|
||||
* "a standing denial always wins" rule themselves rather than trusting the declaration alone.
|
||||
*/
|
||||
suspend fun isDenied(
|
||||
identity: NappletIdentity,
|
||||
capability: NappletCapability,
|
||||
): Boolean = ledger.decide(identity, capability) == PermissionDecision.DENY
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* How long (ms) a Cancel on the first-connect dialog suppresses re-prompting for the same app.
|
||||
@@ -522,5 +542,7 @@ class NappletBroker(
|
||||
* the dialog per request; short enough that a deliberate user retry seconds later prompts again.
|
||||
*/
|
||||
private const val CANCEL_REPROMPT_COOLDOWN_MS = 3_000L
|
||||
private const val RESOURCE_MAX_BYTES = 10L * 1024L * 1024L
|
||||
private const val RESOURCE_MAX_URLS = 16
|
||||
}
|
||||
}
|
||||
|
||||
+14
-2
@@ -156,6 +156,17 @@ class NappletResource(
|
||||
val contentType: String,
|
||||
)
|
||||
|
||||
sealed interface NappletResourceResult {
|
||||
data class Success(
|
||||
val resource: NappletResource,
|
||||
) : NappletResourceResult
|
||||
|
||||
data class Failure(
|
||||
val error: String,
|
||||
val message: String? = null,
|
||||
) : NappletResourceResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges the broker to sandboxed resource fetching for [NappletCapability.RESOURCE]
|
||||
* (`resource.bytes`). The host fetches https/blossom/nostr/data URLs on the applet's behalf —
|
||||
@@ -164,13 +175,14 @@ class NappletResource(
|
||||
*
|
||||
* [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.
|
||||
* applet's/site's network mode. Failures carry the stable NAP-RESOURCE error code rather than
|
||||
* collapsing policy rejections and network failures into one nullable result.
|
||||
*/
|
||||
fun interface NappletResourceGateway {
|
||||
suspend fun fetch(
|
||||
url: String,
|
||||
coordinate: String,
|
||||
): NappletResource?
|
||||
): NappletResourceResult
|
||||
}
|
||||
|
||||
/** A completed upload: where the blob lives plus NIP-94-ish metadata. */
|
||||
|
||||
+14
-20
@@ -25,14 +25,11 @@ package com.vitorpamplona.amethyst.commons.napplet
|
||||
* (`napplet/naps`, `@napplet/web`). A napplet declares the domains it needs via `requires` tags;
|
||||
* [fromNapDomain] maps each bare domain string to the capability the broker enforces.
|
||||
*
|
||||
* The mapping is **default-deny**: an unrecognized NAP domain maps to `null` and the shell must
|
||||
* surface it as unknown rather than silently granting it. Domains we don't yet broker
|
||||
* (`intent`, `media`, `config`, `outbox`, `ifc`, `cvm`) therefore resolve to `null` for now.
|
||||
* The mapping is **default-deny**: an unrecognized or only partially implemented NAP domain maps
|
||||
* to `null`. Keeping a broker implementation below does not advertise conformance; only domains
|
||||
* whose current NAP contract is implemented are injected into `window.napplet`.
|
||||
*/
|
||||
enum class NappletCapability {
|
||||
/** `shell` — capability negotiation (`shell.supports`). Always available; needs no consent. */
|
||||
SHELL,
|
||||
|
||||
/** `identity` — read-only identity queries (`getPublicKey`, `onChanged`). */
|
||||
IDENTITY,
|
||||
|
||||
@@ -70,13 +67,13 @@ enum class NappletCapability {
|
||||
;
|
||||
|
||||
/**
|
||||
* Whether using this capability requires user consent. Negotiation ([SHELL]) and the cosmetic,
|
||||
* read-only theme read ([THEME]) never prompt; everything else does (subject to the broker's
|
||||
* Whether using this capability requires user consent. The cosmetic, read-only theme read
|
||||
* ([THEME]) never prompts; everything else does (subject to the broker's
|
||||
* signer-self-gating and standing-grant rules). [INC] is authorized at the router edge on its
|
||||
* declaration alone, so it never reaches the consent path regardless of this flag.
|
||||
*/
|
||||
val requiresConsent: Boolean
|
||||
get() = this != SHELL && this != THEME
|
||||
get() = this != THEME
|
||||
|
||||
/**
|
||||
* Whether the user must confirm **every single use** — no standing auto-approval. True for
|
||||
@@ -96,25 +93,22 @@ enum class NappletCapability {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Maps a bare NAP domain to the capability the broker enforces, case-insensitively.
|
||||
* Returns `null` for any domain the shell does not recognize — callers MUST treat that as
|
||||
* "unknown, do not grant".
|
||||
* Maps a bare, currently supported NAP domain to the capability the broker enforces.
|
||||
* Returns `null` for unknown and partial/legacy domains — callers MUST treat that as
|
||||
* "unavailable, do not inject or grant". NIP-5D domain names are exact lowercase strings.
|
||||
*/
|
||||
fun fromNapDomain(domain: String): NappletCapability? =
|
||||
when (domain.trim().lowercase()) {
|
||||
"shell" -> SHELL
|
||||
when (domain) {
|
||||
"identity" -> IDENTITY
|
||||
"keys" -> KEYS
|
||||
"relay", "relays" -> RELAY
|
||||
"relay" -> RELAY
|
||||
"storage" -> STORAGE
|
||||
"value" -> VALUE
|
||||
"resource" -> RESOURCE
|
||||
"upload" -> UPLOAD
|
||||
"theme" -> THEME
|
||||
"notify" -> NOTIFY
|
||||
"inc" -> INC
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Exact NIP-5D domain names Amethyst currently exposes through its injection prelude. */
|
||||
val supportedNapDomains: Set<String> = setOf("identity", "relay", "storage", "resource", "theme")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -41,7 +41,15 @@ data class NappletIdentity(
|
||||
val authorPubKey: HexKey,
|
||||
val identifier: String,
|
||||
val aggregateHash: HexKey? = null,
|
||||
/** Opaque host-assigned lifetime id used only for NAP-STORAGE's instance scope. */
|
||||
val instanceId: String? = null,
|
||||
) {
|
||||
/** The ledger key: coordinate only, never the [aggregateHash], so grants survive updates. */
|
||||
val coordinate: String = "$authorPubKey:$identifier"
|
||||
|
||||
/** NAP-STORAGE shared namespace: exact publisher + dTag + verified artifact identity. */
|
||||
val storageCoordinate: String = "$coordinate:${aggregateHash.orEmpty()}"
|
||||
|
||||
/** NAP-STORAGE instance namespace, stable for this host launch and isolated from sibling launches. */
|
||||
val instanceStorageCoordinate: String = "$storageCoordinate:instance:${instanceId.orEmpty()}"
|
||||
}
|
||||
|
||||
+69
-33
@@ -41,33 +41,19 @@ object NappletWebContract {
|
||||
const val SHELL_URL = "$ORIGIN/__shell__"
|
||||
|
||||
/**
|
||||
* The applet runs on its **own per-applet origin** — a unique subdomain of [HOST] — served at the
|
||||
* origin root, NOT on the shell [ORIGIN]. Two reasons, both load-bearing:
|
||||
*
|
||||
* 1. **A real (non-opaque) origin is what gives the applet working, persistent storage.** An
|
||||
* `allow-scripts`-only opaque-origin iframe has no `localStorage`/`IndexedDB`/service worker
|
||||
* (reads throw `SecurityError`), which crash-loops essentially every SPA. A real origin with
|
||||
* `allow-same-origin` has them, scoped and isolated per applet (subdomains don't share
|
||||
* storage), so applets can't read each other's data.
|
||||
* 2. **Keeping it on a DISTINCT origin from the shell is what preserves the trust boundary.** The
|
||||
* native bridge is origin-restricted to the shell [ORIGIN]; the applet, being cross-origin,
|
||||
* still can't reach it (nor read the shell DOM) — it talks only via `postMessage`, which the
|
||||
* shell relays. `allow-same-origin` is therefore safe here precisely because the applet is
|
||||
* same-origin only with *itself*, never with the shell.
|
||||
*
|
||||
* The applet is served at its origin root because SPA bundlers (Vite, CRA, webpack, nsyte, …) emit
|
||||
* **absolute** asset URLs (`/assets/app.js`, `/fonts/x.woff2`) that resolve against the origin root.
|
||||
*
|
||||
* [appId] must be a stable, unique, DNS-label-safe token per applet (the host derives it from the
|
||||
* applet's author + identifier), so the same applet keeps its storage across launches.
|
||||
* Per-site origin retained for Amethyst's NIP-5A WEBSITE profile. NIP-5D napplets never navigate
|
||||
* here: their verified, self-contained `/index.html` is assigned through `srcdoc` and therefore
|
||||
* executes with an opaque origin in an `allow-scripts`-only sandbox.
|
||||
*/
|
||||
fun appOrigin(appId: String): String = "https://$appId.$HOST"
|
||||
|
||||
/** True for the shell host and any per-applet subdomain — i.e. everything we serve internally. */
|
||||
fun isInternalHost(host: String?): Boolean = host == HOST || (host != null && host.endsWith(".$HOST"))
|
||||
|
||||
/** Placeholder in [SHELL_HTML_PATH] the host replaces with the per-applet [appOrigin] before serving. */
|
||||
/** Placeholders in [SHELL_HTML_PATH] replaced by the host before serving the trusted shell. */
|
||||
const val APP_ORIGIN_PLACEHOLDER = "__APP_ORIGIN__"
|
||||
const val APP_SANDBOX_PLACEHOLDER = "__APP_SANDBOX__"
|
||||
const val APP_BOOTSTRAP_PLACEHOLDER = "__APP_BOOTSTRAP__"
|
||||
|
||||
/** Name of the origin-restricted native bridge the shell (and only the shell) can reach. */
|
||||
const val BRIDGE_NAME = "__nappletBridge"
|
||||
@@ -76,24 +62,72 @@ object NappletWebContract {
|
||||
* CSP for the shell document: it may inline its own bridge script/style and frame **only this
|
||||
* applet's** origin, but has no network and cannot navigate or submit anywhere.
|
||||
*/
|
||||
fun shellCsp(appOrigin: String): String =
|
||||
fun shellCsp(frameSource: String): String =
|
||||
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
|
||||
"frame-src $appOrigin; base-uri 'none'; form-action 'none'"
|
||||
"frame-src $frameSource; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
||||
|
||||
/**
|
||||
* CSP for the applet document. The applet has a real origin now, so `'self'` resolves to its own
|
||||
* per-applet origin and the shell origin is deliberately NOT granted. The key lever is
|
||||
* `connect-src 'none'`: the applet gets **no** direct network — every fetch goes through the
|
||||
* brokered, consent-gated `resource.bytes`.
|
||||
* Conservative NIP-5D CSP injected as the first element of the verified napplet's `head` before
|
||||
* the runtime prelude. A `srcdoc` napplet has an opaque origin, so self-hosted subresources are
|
||||
* intentionally unavailable; a conforming napplet is one self-contained `/index.html`.
|
||||
*/
|
||||
const val APP_CSP: String =
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data: blob:; " +
|
||||
"font-src 'self' data:; " +
|
||||
"media-src 'self' blob: data:; " +
|
||||
"connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'"
|
||||
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
|
||||
"img-src data: blob:; font-src data:; connect-src 'none'; worker-src 'none'; " +
|
||||
"child-src 'none'; frame-src 'none'; media-src 'none'; object-src 'none'; " +
|
||||
"manifest-src 'none'; base-uri 'none'; form-action 'none'"
|
||||
|
||||
/**
|
||||
* Injects host-owned policy and the runtime prelude before any authored element in `head`.
|
||||
* [locked] is the NIP-5D posture; WEBSITE callers deliberately retain Amethyst's NIP-07 and
|
||||
* normal-origin behavior. Only syntactically valid, explicitly authorized NAP domains are
|
||||
* projected onto `window.napplet` by the trusted [shimJs].
|
||||
*/
|
||||
fun injectPrelude(
|
||||
html: ByteArray,
|
||||
shimJs: String,
|
||||
declaredDomains: List<String>,
|
||||
locked: Boolean,
|
||||
injectNip07: Boolean = false,
|
||||
imeProxy: Boolean = false,
|
||||
): ByteArray {
|
||||
val text = html.decodeToString()
|
||||
val policy =
|
||||
if (locked) {
|
||||
"<meta http-equiv=\"Content-Security-Policy\" content=\"$APP_CSP\">"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val style = "<style>html,body{overscroll-behavior:none !important}</style>"
|
||||
val flags =
|
||||
"<script>window.__nappletNip07=$injectNip07;" +
|
||||
(if (imeProxy) "window.__nappletImeProxy=true;" else "") +
|
||||
"</script>"
|
||||
val safeDomains =
|
||||
declaredDomains
|
||||
.filter { it.matches(NAP_DOMAIN) && it in NappletCapability.supportedNapDomains }
|
||||
.distinct()
|
||||
val domainsJson = safeDomains.joinToString(prefix = "[", postfix = "]") { "\"$it\"" }
|
||||
val prelude = "$policy$style$flags<script>window.__nappletDomains=$domainsJson;$shimJs</script>"
|
||||
val headIdx = text.indexOf("<head", ignoreCase = true)
|
||||
val injected =
|
||||
when {
|
||||
headIdx >= 0 -> {
|
||||
val close = text.indexOf('>', headIdx)
|
||||
if (close >= 0) text.substring(0, close + 1) + prelude + text.substring(close + 1) else prelude + text
|
||||
}
|
||||
else -> {
|
||||
val htmlIdx = text.indexOf("<html", ignoreCase = true)
|
||||
val htmlClose = if (htmlIdx >= 0) text.indexOf('>', htmlIdx) else -1
|
||||
if (htmlClose >= 0) {
|
||||
text.substring(0, htmlClose + 1) + "<head>$prelude</head>" + text.substring(htmlClose + 1)
|
||||
} else {
|
||||
"<head>$prelude</head>$text"
|
||||
}
|
||||
}
|
||||
}
|
||||
return injected.encodeToByteArray()
|
||||
}
|
||||
|
||||
const val SHELL_HTML_PATH = "files/napplet/shell.html"
|
||||
const val SHIM_JS_PATH = "files/napplet/shim.js"
|
||||
@@ -114,4 +148,6 @@ object NappletWebContract {
|
||||
/** The `window.napplet` client shim a host injects into the applet document. */
|
||||
@OptIn(ExperimentalResourceApi::class)
|
||||
suspend fun shimJs(): ByteArray = Res.readBytes(SHIM_JS_PATH)
|
||||
|
||||
private val NAP_DOMAIN = Regex("^[a-z][a-z0-9-]*$")
|
||||
}
|
||||
|
||||
+23
-9
@@ -24,6 +24,11 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
|
||||
enum class NappletStorageScope {
|
||||
SHARED,
|
||||
INSTANCE,
|
||||
}
|
||||
|
||||
/**
|
||||
* A capability request from a napplet, after it has crossed the postMessage + IPC edge
|
||||
* and been deserialized into a typed object. Each variant declares the [capability] the
|
||||
@@ -68,14 +73,6 @@ sealed interface NappletRequest {
|
||||
override val capability get() = NappletCapability.THEME
|
||||
}
|
||||
|
||||
/** `shell.supports(domain, protocol?)` — capability negotiation; always answerable, no consent. */
|
||||
data class ShellSupports(
|
||||
val domain: String,
|
||||
val protocol: String? = null,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.SHELL
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an event built from an **unsigned template**. The napplet supplies only `kind`,
|
||||
* `tags`, and `content`; the shell sets `pubkey` from the real signer, stamps `created_at`,
|
||||
@@ -198,6 +195,7 @@ sealed interface NappletRequest {
|
||||
/** Read a value from this napplet's sandboxed key-value store (`storage.getItem`). */
|
||||
data class StorageGet(
|
||||
val key: String,
|
||||
val scope: NappletStorageScope = NappletStorageScope.SHARED,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.STORAGE
|
||||
}
|
||||
@@ -206,6 +204,7 @@ sealed interface NappletRequest {
|
||||
data class StorageSet(
|
||||
val key: String,
|
||||
val value: String,
|
||||
val scope: NappletStorageScope = NappletStorageScope.SHARED,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.STORAGE
|
||||
}
|
||||
@@ -213,12 +212,15 @@ sealed interface NappletRequest {
|
||||
/** Remove a value from this napplet's sandboxed key-value store (`storage.removeItem`). */
|
||||
data class StorageRemove(
|
||||
val key: String,
|
||||
val scope: NappletStorageScope = NappletStorageScope.SHARED,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.STORAGE
|
||||
}
|
||||
|
||||
/** List the keys this napplet has stored (`storage.keys`). */
|
||||
data object StorageKeys : NappletRequest {
|
||||
data class StorageKeys(
|
||||
val scope: NappletStorageScope = NappletStorageScope.SHARED,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.STORAGE
|
||||
}
|
||||
|
||||
@@ -282,6 +284,18 @@ sealed interface NappletRequest {
|
||||
override val capability get() = NappletCapability.RESOURCE
|
||||
}
|
||||
|
||||
/** Describe the bounded schemes and limits of this shell's existing resource broker. */
|
||||
data object ResourceInfo : NappletRequest {
|
||||
override val capability get() = NappletCapability.RESOURCE
|
||||
}
|
||||
|
||||
/** Fetch several resources in input order, returning a per-URL success/error record. */
|
||||
data class ResourceBytesMany(
|
||||
val urls: List<String>,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.RESOURCE
|
||||
}
|
||||
|
||||
/** Upload a blob to the user's Blossom server (`upload.upload`). */
|
||||
data class UploadBlob(
|
||||
val bytes: ByteArray,
|
||||
|
||||
+22
-5
@@ -51,11 +51,6 @@ sealed interface NappletResponse {
|
||||
val events: List<Event>,
|
||||
) : NappletResponse
|
||||
|
||||
/** Result of `shell.supports(domain)`. */
|
||||
data class Supported(
|
||||
val supported: Boolean,
|
||||
) : NappletResponse
|
||||
|
||||
/** Result of `keys.registerAction`: the shell-assigned [actionId] and the [binding] it honored (e.g. `"Ctrl+S"`). */
|
||||
data class ActionRegistered(
|
||||
val actionId: String,
|
||||
@@ -97,6 +92,28 @@ sealed interface NappletResponse {
|
||||
override fun hashCode(): Int = 31 * contentType.hashCode() + bytes.contentHashCode()
|
||||
}
|
||||
|
||||
data class ResourceInfo(
|
||||
val schemes: List<String>,
|
||||
val maxBytes: Long,
|
||||
val maxUrls: Int,
|
||||
) : NappletResponse
|
||||
|
||||
data class ResourceItem(
|
||||
val url: String,
|
||||
val resource: Bytes? = null,
|
||||
val error: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
data class ResourceItems(
|
||||
val items: List<ResourceItem>,
|
||||
) : NappletResponse
|
||||
|
||||
data class ResourceFailure(
|
||||
val error: String,
|
||||
val message: String? = null,
|
||||
) : NappletResponse
|
||||
|
||||
/** Result of an `upload.upload`; [url] is where the blob can be fetched, plus NIP-94-ish metadata. */
|
||||
data class Uploaded(
|
||||
val url: String,
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.commons.napplet
|
||||
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class NappletArtifactPolicyTest {
|
||||
private val htmlHash = "11".repeat(32)
|
||||
private val index = PathTag("/index.html", htmlHash)
|
||||
|
||||
@Test
|
||||
fun computesIdentityFromTheSignedSingleIndexPath() {
|
||||
assertEquals(
|
||||
SiteAggregateHash.compute(listOf(index)),
|
||||
NappletArtifactPolicy.verifiedAggregateHash(listOf(index), null),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsMatchingDeclaredIdentityCaseInsensitively() {
|
||||
val computed = SiteAggregateHash.compute(listOf(index))
|
||||
assertEquals(computed, NappletArtifactPolicy.verifiedAggregateHash(listOf(index), computed.uppercase()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsDriftedOrNonSelfContainedArtifacts() {
|
||||
assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(index), "22".repeat(32)))
|
||||
assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(PathTag("index.html", htmlHash)), null))
|
||||
assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(index, PathTag("/app.js", "22".repeat(32))), null))
|
||||
assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(PathTag("/index.html", "not-a-sha256")), null))
|
||||
}
|
||||
}
|
||||
+69
-17
@@ -182,9 +182,18 @@ class NappletBrokerTest {
|
||||
relay: NappletRelayGateway? = null,
|
||||
storage: NappletStorage? = null,
|
||||
wallet: NappletWalletGateway? = null,
|
||||
resource: NappletResourceGateway? = null,
|
||||
signer: NostrSigner = this.signer,
|
||||
ledger: NappletPermissionLedger = NappletPermissionLedger(InMemoryNappletPermissionStore()),
|
||||
) = NappletBroker(signer, ledger, prompt, relay, storage, wallet)
|
||||
) = NappletBroker(
|
||||
signer = signer,
|
||||
ledger = ledger,
|
||||
consentPrompt = prompt,
|
||||
relay = relay,
|
||||
storage = storage,
|
||||
wallet = wallet,
|
||||
resource = resource,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun getPublicKeyReturnsTheUsersKeyWhenAllowed() =
|
||||
@@ -545,8 +554,8 @@ class NappletBrokerTest {
|
||||
assertEquals(NappletResponse.Done, broker.handle(applet, NappletRequest.StorageSet("k", "v"), allDeclared))
|
||||
assertEquals(NappletResponse.StorageValue("v"), broker.handle(applet, NappletRequest.StorageGet("k"), allDeclared))
|
||||
|
||||
// Stored under the applet coordinate, never a shared namespace.
|
||||
assertEquals("v", storage.data["${applet.coordinate}::k"])
|
||||
// Shared storage is scoped to the verified artifact identity, not just the d-tag.
|
||||
assertEquals("v", storage.data["${applet.storageCoordinate}::k"])
|
||||
|
||||
assertEquals(NappletResponse.Done, broker.handle(applet, NappletRequest.StorageRemove("k"), allDeclared))
|
||||
assertEquals(NappletResponse.StorageValue(null), broker.handle(applet, NappletRequest.StorageGet("k"), allDeclared))
|
||||
@@ -563,7 +572,7 @@ class NappletBrokerTest {
|
||||
broker.handle(applet, NappletRequest.StorageSet("b", "2"), allDeclared)
|
||||
broker.handle(other, NappletRequest.StorageSet("c", "3"), allDeclared)
|
||||
|
||||
val response = broker.handle(applet, NappletRequest.StorageKeys, allDeclared)
|
||||
val response = broker.handle(applet, NappletRequest.StorageKeys(), allDeclared)
|
||||
assertIs<NappletResponse.Strings>(response)
|
||||
assertEquals(setOf("a", "b"), response.values.toSet()) // never sees the other applet's "c"
|
||||
}
|
||||
@@ -643,19 +652,6 @@ class NappletBrokerTest {
|
||||
assertIs<NappletResponse.Denied>(response)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shellSupportsReflectsDeclaredCapabilitiesWithoutConsent() =
|
||||
runTest {
|
||||
// The DENY prompt would block anything that reached consent; supports must not.
|
||||
val broker = broker(ScriptedPrompt(GrantState.DENY))
|
||||
val declared = setOf(NappletCapability.RELAY)
|
||||
|
||||
assertEquals(NappletResponse.Supported(true), broker.handle(applet, NappletRequest.ShellSupports("relay"), declared))
|
||||
assertEquals(NappletResponse.Supported(false), broker.handle(applet, NappletRequest.ShellSupports("storage"), declared))
|
||||
// Unknown/unbrokered domain.
|
||||
assertEquals(NappletResponse.Supported(false), broker.handle(applet, NappletRequest.ShellSupports("cvm"), declared))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun identityReadReturnsGatewayJsonOrUnsupported() =
|
||||
runTest {
|
||||
@@ -693,4 +689,60 @@ class NappletBrokerTest {
|
||||
assertIs<NappletResponse.Unsupported>(broker.handle(applet, NappletRequest.ResourceBytes("https://x"), allDeclared))
|
||||
assertIs<NappletResponse.Unsupported>(broker.handle(applet, NappletRequest.UploadBlob(ByteArray(0), "image/png"), allDeclared))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resourceInfoAndTypedFailuresFollowTheCurrentNapContract() =
|
||||
runTest {
|
||||
val resource =
|
||||
NappletResourceGateway { _, _ ->
|
||||
NappletResourceResult.Failure("blocked-by-policy", "private target")
|
||||
}
|
||||
val broker = broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS), resource = resource)
|
||||
|
||||
val info = broker.handle(applet, NappletRequest.ResourceInfo, allDeclared)
|
||||
assertIs<NappletResponse.ResourceInfo>(info)
|
||||
assertEquals(listOf("data", "https", "blossom", "nostr"), info.schemes)
|
||||
assertEquals(10L * 1024L * 1024L, info.maxBytes)
|
||||
|
||||
assertEquals(
|
||||
NappletResponse.ResourceFailure("blocked-by-policy", "private target"),
|
||||
broker.handle(applet, NappletRequest.ResourceBytes("https://internal.example"), allDeclared),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resourceBytesManyPreservesOrderAndSiblingResults() =
|
||||
runTest {
|
||||
val resource =
|
||||
NappletResourceGateway { url, _ ->
|
||||
if (url.endsWith("ok")) {
|
||||
NappletResourceResult.Success(NappletResource("ok".encodeToByteArray(), "text/plain"))
|
||||
} else {
|
||||
NappletResourceResult.Failure("not-found")
|
||||
}
|
||||
}
|
||||
val response =
|
||||
broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS), resource = resource)
|
||||
.handle(applet, NappletRequest.ResourceBytesMany(listOf("https://x/ok", "https://x/missing")), allDeclared)
|
||||
|
||||
assertIs<NappletResponse.ResourceItems>(response)
|
||||
assertEquals(listOf("https://x/ok", "https://x/missing"), response.items.map { it.url })
|
||||
assertIs<NappletResponse.Bytes>(response.items[0].resource)
|
||||
assertEquals("not-found", response.items[1].error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resourceBytesManyRejectsInvalidBulkSizesWithNapErrorCodes() =
|
||||
runTest {
|
||||
val resource = NappletResourceGateway { _, _ -> error("invalid bulk must not fetch") }
|
||||
val broker = broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS), resource = resource)
|
||||
|
||||
val empty = broker.handle(applet, NappletRequest.ResourceBytesMany(emptyList()), allDeclared)
|
||||
val tooLarge = broker.handle(applet, NappletRequest.ResourceBytesMany(List(17) { "data:,x" }), allDeclared)
|
||||
|
||||
assertIs<NappletResponse.ResourceFailure>(empty)
|
||||
assertEquals("invalid-request", empty.error)
|
||||
assertIs<NappletResponse.ResourceFailure>(tooLarge)
|
||||
assertEquals("too-large", tooLarge.error)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-11
@@ -28,18 +28,12 @@ import kotlin.test.assertTrue
|
||||
|
||||
class NappletCapabilityTest {
|
||||
@Test
|
||||
fun mapsKnownDomainsCaseInsensitively() {
|
||||
assertEquals(NappletCapability.SHELL, NappletCapability.fromNapDomain("shell"))
|
||||
fun mapsOnlyConformingDomainsExactly() {
|
||||
assertEquals(NappletCapability.IDENTITY, NappletCapability.fromNapDomain("identity"))
|
||||
assertEquals(NappletCapability.KEYS, NappletCapability.fromNapDomain("keys"))
|
||||
assertEquals(NappletCapability.RELAY, NappletCapability.fromNapDomain("Relay"))
|
||||
assertEquals(NappletCapability.VALUE, NappletCapability.fromNapDomain("value"))
|
||||
assertEquals(NappletCapability.STORAGE, NappletCapability.fromNapDomain(" STORAGE "))
|
||||
assertEquals(NappletCapability.RELAY, NappletCapability.fromNapDomain("relay"))
|
||||
assertEquals(NappletCapability.STORAGE, NappletCapability.fromNapDomain("storage"))
|
||||
assertEquals(NappletCapability.RESOURCE, NappletCapability.fromNapDomain("resource"))
|
||||
assertEquals(NappletCapability.UPLOAD, NappletCapability.fromNapDomain("upload"))
|
||||
assertEquals(NappletCapability.THEME, NappletCapability.fromNapDomain("theme"))
|
||||
assertEquals(NappletCapability.NOTIFY, NappletCapability.fromNapDomain("notify"))
|
||||
assertEquals(NappletCapability.INC, NappletCapability.fromNapDomain("inc"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -48,6 +42,14 @@ class NappletCapabilityTest {
|
||||
assertNull(NappletCapability.fromNapDomain("intent"))
|
||||
assertNull(NappletCapability.fromNapDomain("cvm"))
|
||||
assertNull(NappletCapability.fromNapDomain("filesystem"))
|
||||
assertNull(NappletCapability.fromNapDomain("shell"))
|
||||
assertNull(NappletCapability.fromNapDomain("keys"))
|
||||
assertNull(NappletCapability.fromNapDomain("value"))
|
||||
assertNull(NappletCapability.fromNapDomain("upload"))
|
||||
assertNull(NappletCapability.fromNapDomain("notify"))
|
||||
assertNull(NappletCapability.fromNapDomain("inc"))
|
||||
assertNull(NappletCapability.fromNapDomain("Relay"))
|
||||
assertNull(NappletCapability.fromNapDomain(" storage "))
|
||||
assertNull(NappletCapability.fromNapDomain(""))
|
||||
}
|
||||
|
||||
@@ -56,10 +58,10 @@ class NappletCapabilityTest {
|
||||
val resolved = resolveRequiredCapabilities(listOf("identity", "relay", "intent", "value"))
|
||||
|
||||
assertEquals(
|
||||
setOf(NappletCapability.IDENTITY, NappletCapability.RELAY, NappletCapability.VALUE),
|
||||
setOf(NappletCapability.IDENTITY, NappletCapability.RELAY),
|
||||
resolved.capabilities,
|
||||
)
|
||||
assertEquals(listOf(UnknownNapDomain("intent")), resolved.unknown)
|
||||
assertEquals(listOf(UnknownNapDomain("intent"), UnknownNapDomain("value")), resolved.unknown)
|
||||
assertTrue(resolved.hasUnknown)
|
||||
}
|
||||
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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.commons.napplet
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContains
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class NappletWebContractTest {
|
||||
@Test
|
||||
fun lockedPreludeIsTheFirstHeadContentAndRunsBeforeAuthoredCode() {
|
||||
val authored = "<html><head><script id=\"authored\">run()</script></head><body></body></html>"
|
||||
val injected =
|
||||
NappletWebContract
|
||||
.injectPrelude(
|
||||
html = authored.encodeToByteArray(),
|
||||
shimJs = "window.__shimRan=true;",
|
||||
declaredDomains = listOf("identity", "relay", "shell", "Relay", "bad\"domain", "relay"),
|
||||
locked = true,
|
||||
).decodeToString()
|
||||
|
||||
val head = injected.indexOf("<head>") + "<head>".length
|
||||
val csp = injected.indexOf("<meta http-equiv=\"Content-Security-Policy\"")
|
||||
val domains = injected.indexOf("window.__nappletDomains=[\"identity\", \"relay\"]")
|
||||
val shim = injected.indexOf("window.__shimRan=true")
|
||||
val authoredScript = injected.indexOf("id=\"authored\"")
|
||||
|
||||
assertTrue(head == csp, "the CSP meta must be the first element in head")
|
||||
assertTrue(csp < domains && domains < shim && shim < authoredScript)
|
||||
assertContains(injected, "window.__nappletNip07=false")
|
||||
assertFalse(injected.contains("\"shell\""))
|
||||
assertFalse(injected.contains("bad\"domain"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lockedCspMatchesTheConservativeOpaqueOriginPosture() {
|
||||
val csp = NappletWebContract.APP_CSP
|
||||
|
||||
assertContains(csp, "default-src 'none'")
|
||||
assertContains(csp, "connect-src 'none'")
|
||||
assertContains(csp, "frame-src 'none'")
|
||||
assertContains(csp, "object-src 'none'")
|
||||
assertContains(csp, "base-uri 'none'")
|
||||
assertContains(csp, "form-action 'none'")
|
||||
assertFalse(csp.contains("'self'"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shellTemplateKeepsTheOpaqueIframeAndSourceChecks() =
|
||||
runTest {
|
||||
val shell = NappletWebContract.shellHtml().decodeToString()
|
||||
|
||||
assertContains(shell, "sandbox=\"${NappletWebContract.APP_SANDBOX_PLACEHOLDER}\"")
|
||||
assertContains(shell, NappletWebContract.APP_BOOTSTRAP_PLACEHOLDER)
|
||||
assertContains(shell, "e.source !== iframe.contentWindow")
|
||||
assertContains(shell, "iframe.contentWindow.postMessage(msg, '*')")
|
||||
assertFalse(shell.contains("allow-same-origin"))
|
||||
}
|
||||
}
|
||||
+4
-26
@@ -55,12 +55,6 @@ object NappletRequestRouter {
|
||||
val subId: String,
|
||||
) : Outcome
|
||||
|
||||
/** Start streaming `identity.changed` pushes when the active user's key changes (fire-and-forget). */
|
||||
data object WatchIdentity : Outcome
|
||||
|
||||
/** Stop the `identity.changed` stream (fire-and-forget; no reply). */
|
||||
data object UnwatchIdentity : Outcome
|
||||
|
||||
/** Push these envelope(s) to the applet immediately, unkeyed (e.g. an EOSE closing a refused sub). */
|
||||
data class Push(
|
||||
val payloads: List<String>,
|
||||
@@ -89,7 +83,7 @@ object NappletRequestRouter {
|
||||
declared: Set<NappletCapability>,
|
||||
payload: String,
|
||||
): Outcome {
|
||||
val requestType = runCatching { NappletProtocolJson.readType(payload) }.getOrNull() ?: "napplet"
|
||||
val requestType = runCatching { NappletProtocolJson.readType(payload) }.getOrNull() ?: return Outcome.Ignore
|
||||
|
||||
// Fire-and-forget edge ops that never reach the broker.
|
||||
when (requestType) {
|
||||
@@ -97,25 +91,9 @@ object NappletRequestRouter {
|
||||
val subId = runCatching { NappletProtocolJson.readSubId(payload) }.getOrNull()
|
||||
return if (subId != null) Outcome.CloseSubscription(subId) else Outcome.Ignore
|
||||
}
|
||||
"resource.cancel" ->
|
||||
return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, NappletResponse.Done))
|
||||
// identity.watch/unwatch are a push subscription (like relay.subscribe), gated on the
|
||||
// IDENTITY declaration directly — the actual pubkey stream is the host's job. The
|
||||
// ledger still gets a say: this bypasses NappletBroker.handle, so without an explicit
|
||||
// check a standing IDENTITY denial would not stop the pushes (and would keep leaking
|
||||
// account-switch timing and every npub the user rotates between).
|
||||
"identity.watch" ->
|
||||
return if (NappletCapability.IDENTITY in declared &&
|
||||
!broker.isDenied(identity, NappletCapability.IDENTITY)
|
||||
) {
|
||||
Outcome.WatchIdentity
|
||||
} else {
|
||||
Outcome.Ignore
|
||||
}
|
||||
"identity.unwatch" ->
|
||||
return Outcome.UnwatchIdentity
|
||||
"resource.cancel" -> return Outcome.Ignore
|
||||
// inc bus: a topic pub/sub between napplets/services, authorized on the INC declaration
|
||||
// alone (like identity.watch) — no per-call consent. The host owns the cross-session fan-out.
|
||||
// alone. The host owns the cross-session fan-out.
|
||||
"inc.subscribe" -> {
|
||||
val topic = runCatching { NappletProtocolJson.readTopic(payload) }.getOrNull()
|
||||
return if (topic != null && NappletCapability.INC in declared) Outcome.SubscribeInc(topic) else Outcome.Ignore
|
||||
@@ -136,7 +114,7 @@ object NappletRequestRouter {
|
||||
|
||||
val request =
|
||||
runCatching { NappletProtocolJson.decodeRequest(payload) }.getOrNull()
|
||||
?: return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Malformed or unsupported request.")))
|
||||
?: return Outcome.Ignore
|
||||
|
||||
val response = broker.handle(identity, request, declared)
|
||||
|
||||
|
||||
+69
-31
@@ -26,6 +26,7 @@ import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.int
|
||||
@@ -34,6 +35,7 @@ import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import java.util.Base64
|
||||
|
||||
@@ -62,7 +64,7 @@ object NappletProtocolJson {
|
||||
/** The `subId` of a subscription request, used to key the `relay.event`/`relay.eose` pushes back to it. */
|
||||
fun readSubId(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("subId")
|
||||
|
||||
/** A `relay.event` push: delivers one matching [event] to the subscription [subId] (no request id). */
|
||||
/** A `relay.event` push carrying the NAP-RELAY `RelayEventResult` wrapper. */
|
||||
fun encodeRelayEvent(
|
||||
subId: String,
|
||||
event: Event,
|
||||
@@ -70,7 +72,9 @@ object NappletProtocolJson {
|
||||
buildJsonObject {
|
||||
put("type", "relay.event")
|
||||
put("subId", subId)
|
||||
put("event", json.parseToJsonElement(event.toJson()))
|
||||
putJsonObject("result") {
|
||||
put("event", json.parseToJsonElement(event.toJson()))
|
||||
}
|
||||
}.toString()
|
||||
|
||||
/** A `relay.eose` push: signals end-of-stored-events for the subscription [subId]. */
|
||||
@@ -130,29 +134,10 @@ object NappletProtocolJson {
|
||||
put("reason", reason)
|
||||
}.toString()
|
||||
|
||||
/**
|
||||
* The `shell.init` handshake reply (`@napplet/core`): the capability environment the napplet
|
||||
* caches and answers `shell.supports()` from. [domains] is the set of NAP domains this shell
|
||||
* will broker for the applet; [services] mirrors them. We don't advertise numbered protocols.
|
||||
*/
|
||||
fun encodeShellInit(
|
||||
domains: List<String>,
|
||||
services: List<String>,
|
||||
): String =
|
||||
buildJsonObject {
|
||||
put("type", "shell.init")
|
||||
putJsonObject("capabilities") {
|
||||
put("domains", buildJsonArray { domains.forEach { add(it) } })
|
||||
putJsonObject("protocols") {}
|
||||
}
|
||||
put("services", buildJsonArray { services.forEach { add(it) } })
|
||||
}.toString()
|
||||
|
||||
/** Parses a request envelope. Returns `null` for an unrecognized `type` so the broker can deny it. */
|
||||
fun decodeRequest(envelopeJson: String): NappletRequest? {
|
||||
val o = json.parseToJsonElement(envelopeJson).jsonObject
|
||||
return when (o.str("type")) {
|
||||
"shell.supports" -> NappletRequest.ShellSupports(o.req("domain"), o.str("protocol"))
|
||||
"theme.get" -> NappletRequest.ThemeGet
|
||||
"identity.getPublicKey" -> NappletRequest.GetPublicKey
|
||||
"relay.publish" -> {
|
||||
@@ -181,10 +166,10 @@ object NappletProtocolJson {
|
||||
createdAt = t["created_at"]?.jsonPrimitive?.long ?: (System.currentTimeMillis() / 1000),
|
||||
)
|
||||
}
|
||||
"storage.get" -> NappletRequest.StorageGet(o.req("key"))
|
||||
"storage.set" -> NappletRequest.StorageSet(o.req("key"), o.req("value"))
|
||||
"storage.remove" -> NappletRequest.StorageRemove(o.req("key"))
|
||||
"storage.keys" -> NappletRequest.StorageKeys
|
||||
"storage.get" -> NappletRequest.StorageGet(o.req("key"), o.storageScope())
|
||||
"storage.set" -> NappletRequest.StorageSet(o.req("key"), o.req("value"), o.storageScope())
|
||||
"storage.remove" -> NappletRequest.StorageRemove(o.req("key"), o.storageScope())
|
||||
"storage.keys" -> NappletRequest.StorageKeys(o.storageScope())
|
||||
"notify.create" -> NappletRequest.NotifyCreate(o.str("title") ?: "", o.str("body") ?: "")
|
||||
"notify.list" -> NappletRequest.NotifyList
|
||||
"notify.dismiss" -> NappletRequest.NotifyDismiss(o.str("notificationId") ?: o.str("id") ?: "")
|
||||
@@ -194,7 +179,9 @@ object NappletProtocolJson {
|
||||
}
|
||||
"keys.unregisterAction" -> NappletRequest.UnregisterAction(o.req("actionId"))
|
||||
"value.payInvoice" -> NappletRequest.PayInvoice(o.req("invoice"))
|
||||
"resource.info" -> NappletRequest.ResourceInfo
|
||||
"resource.bytes" -> NappletRequest.ResourceBytes(o.req("url"))
|
||||
"resource.bytesMany" -> NappletRequest.ResourceBytesMany(o.getValue("urls").jsonArray.map { it.jsonPrimitive.content })
|
||||
"upload.upload" -> {
|
||||
// UploadUploadMessage: { type, id, request: { data, mimeType?, filename?, ... } }.
|
||||
// The Blob in `request.data` is inlined as base64 `request.dataBase64` by shell.html.
|
||||
@@ -209,7 +196,7 @@ object NappletProtocolJson {
|
||||
// Any other identity.* read (getProfile/getRelays/getFollows/getList/...) routes through
|
||||
// a generic IdentityRead; the broker/gateway decides which are implemented.
|
||||
val type = o.str("type")
|
||||
if (type != null && type.startsWith("identity.")) {
|
||||
if (type != null && type in IDENTITY_READ_TYPES) {
|
||||
NappletRequest.IdentityRead(type.removePrefix("identity."), o.str("listType") ?: o.str("argument"))
|
||||
} else {
|
||||
null
|
||||
@@ -230,6 +217,7 @@ object NappletProtocolJson {
|
||||
when (response) {
|
||||
is NappletResponse.NotifyCreated -> "notify.created"
|
||||
is NappletResponse.NotifyListed -> "notify.listed"
|
||||
is NappletResponse.ResourceFailure -> "$requestType.error"
|
||||
else -> "$requestType.result"
|
||||
}
|
||||
put("type", responseType)
|
||||
@@ -247,11 +235,11 @@ object NappletProtocolJson {
|
||||
}
|
||||
is NappletResponse.Events -> {
|
||||
put("ok", true)
|
||||
put("events", buildJsonArray { response.events.forEach { add(json.parseToJsonElement(it.toJson())) } })
|
||||
}
|
||||
is NappletResponse.Supported -> {
|
||||
put("ok", true)
|
||||
put("supported", response.supported)
|
||||
putJsonArray("events") {
|
||||
response.events.forEach { event ->
|
||||
addJsonObject { put("event", json.parseToJsonElement(event.toJson())) }
|
||||
}
|
||||
}
|
||||
}
|
||||
is NappletResponse.ActionRegistered -> {
|
||||
put("ok", true)
|
||||
@@ -279,6 +267,42 @@ object NappletProtocolJson {
|
||||
put("bytes", Base64.getEncoder().encodeToString(response.bytes))
|
||||
put("mime", response.contentType)
|
||||
}
|
||||
is NappletResponse.ResourceInfo -> {
|
||||
put("ok", true)
|
||||
putJsonObject("info") {
|
||||
putJsonArray("schemes") {
|
||||
response.schemes.forEach { scheme ->
|
||||
addJsonObject {
|
||||
put("scheme", scheme)
|
||||
put("enabled", true)
|
||||
}
|
||||
}
|
||||
}
|
||||
put("maxBytes", response.maxBytes)
|
||||
put("maxUrls", response.maxUrls)
|
||||
}
|
||||
}
|
||||
is NappletResponse.ResourceItems -> {
|
||||
put("ok", true)
|
||||
putJsonArray("items") {
|
||||
response.items.forEach { item ->
|
||||
addJsonObject {
|
||||
put("url", item.url)
|
||||
put("ok", item.resource != null)
|
||||
item.resource?.let {
|
||||
put("bytes", Base64.getEncoder().encodeToString(it.bytes))
|
||||
put("mime", it.contentType)
|
||||
}
|
||||
item.error?.let { put("error", it) }
|
||||
item.message?.let { put("message", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is NappletResponse.ResourceFailure -> {
|
||||
put("error", response.error)
|
||||
response.message?.let { put("message", it) }
|
||||
}
|
||||
is NappletResponse.Uploaded -> {
|
||||
// UploadResult: { ok, uploadId, status, url?, sha256?, size?, mimeType?, ... }.
|
||||
put("ok", true)
|
||||
@@ -397,6 +421,8 @@ object NappletProtocolJson {
|
||||
|
||||
private fun JsonObject.kindOf(): Int = getValue("kind").jsonPrimitive.int
|
||||
|
||||
private fun JsonObject.storageScope(): NappletStorageScope = if (str("scope") == "instance") NappletStorageScope.INSTANCE else NappletStorageScope.SHARED
|
||||
|
||||
/** The result field a given identity read returns, matching `@napplet/nap` identity message types. */
|
||||
private fun identityResultField(requestType: String): String =
|
||||
when (requestType) {
|
||||
@@ -408,4 +434,16 @@ object NappletProtocolJson {
|
||||
"identity.getBadges" -> "badges"
|
||||
else -> "result"
|
||||
}
|
||||
|
||||
private val IDENTITY_READ_TYPES =
|
||||
setOf(
|
||||
"identity.getRelays",
|
||||
"identity.getProfile",
|
||||
"identity.getFollows",
|
||||
"identity.getList",
|
||||
"identity.getZaps",
|
||||
"identity.getMutes",
|
||||
"identity.getBlocked",
|
||||
"identity.getBadges",
|
||||
)
|
||||
}
|
||||
|
||||
+12
-31
@@ -85,11 +85,12 @@ class NappletRequestRouterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resourceCancelRepliesDone() =
|
||||
fun resourceCancelIsSilentlyHandled() =
|
||||
runTest {
|
||||
val outcome = route("""{"type":"resource.cancel"}""")
|
||||
assertIs<NappletRequestRouter.Outcome.Reply>(outcome)
|
||||
assertTrue(outcome.payload.contains("resource.cancel.result"))
|
||||
assertEquals(
|
||||
NappletRequestRouter.Outcome.Ignore,
|
||||
route("""{"type":"resource.cancel"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -117,11 +118,11 @@ class NappletRequestRouterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedRequestRepliesFailed() =
|
||||
fun unknownAndMalformedRequestsAreSilentlyIgnored() =
|
||||
runTest {
|
||||
val outcome = route("""{"type":"totally.unknown"}""")
|
||||
assertIs<NappletRequestRouter.Outcome.Reply>(outcome)
|
||||
assertTrue(outcome.payload.contains("failed"))
|
||||
assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"totally.unknown"}"""))
|
||||
assertEquals(NappletRequestRouter.Outcome.Ignore, route("not json"))
|
||||
assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"relay.publish"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -153,29 +154,9 @@ class NappletRequestRouterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun identityWatchWhenDeclaredBecomesWatchIdentity() =
|
||||
fun removedIdentityWatchMessagesAreIgnored() =
|
||||
runTest {
|
||||
assertEquals(
|
||||
NappletRequestRouter.Outcome.WatchIdentity,
|
||||
NappletRequestRouter.route(broker(), applet, allDeclared, """{"type":"identity.watch"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun identityWatchWithoutDeclarationIsIgnored() =
|
||||
runTest {
|
||||
assertEquals(
|
||||
NappletRequestRouter.Outcome.Ignore,
|
||||
NappletRequestRouter.route(broker(), applet, emptySet(), """{"type":"identity.watch"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun identityUnwatchBecomesUnwatchIdentity() =
|
||||
runTest {
|
||||
assertEquals(
|
||||
NappletRequestRouter.Outcome.UnwatchIdentity,
|
||||
NappletRequestRouter.route(broker(), applet, emptySet(), """{"type":"identity.unwatch"}"""),
|
||||
)
|
||||
assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"identity.watch"}"""))
|
||||
assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"identity.unwatch"}"""))
|
||||
}
|
||||
}
|
||||
|
||||
+47
-29
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplethost
|
||||
|
||||
import android.util.Base64
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
|
||||
@@ -60,6 +61,9 @@ class NappletContentServer(
|
||||
// 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,
|
||||
// Exact NAP domains the trusted main process authorized for this launch. The injected prelude
|
||||
// projects only these objects; absence is the NIP-5D capability-availability signal.
|
||||
private val declaredDomains: List<String> = emptyList(),
|
||||
// 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.
|
||||
@@ -130,15 +134,44 @@ class NappletContentServer(
|
||||
}
|
||||
|
||||
private fun serveShell(): WebResourceResponse {
|
||||
// The shell HTML carries an APP_ORIGIN_PLACEHOLDER for the iframe src; bind it to this applet's
|
||||
// origin so the shell frames exactly this applet (and the CSP frame-src is pinned to it too).
|
||||
val html = shellHtmlBytes.decodeToString().replace(NappletWebContract.APP_ORIGIN_PLACEHOLDER, appOrigin).encodeToByteArray()
|
||||
val sandbox: String
|
||||
val frameSource: String
|
||||
val bootstrap: String
|
||||
|
||||
if (profile == HostProfile.NAPPLET) {
|
||||
// NIP-5D identity is bound to the exact verified bytes that execute. Resolve the sole
|
||||
// /index.html blob, inject host-owned policy/prelude outside its aggregate hash, then
|
||||
// hand those bytes to the opaque-origin child via srcdoc (never a navigated src).
|
||||
val resolution = resolveCacheFirst("/index.html")
|
||||
if (resolution !is StaticSiteResolution.Resolved) return notFound()
|
||||
val encoded = Base64.encodeToString(injectShim(resolution.bytes), Base64.NO_WRAP)
|
||||
sandbox = "allow-scripts"
|
||||
frameSource = "'self'"
|
||||
bootstrap =
|
||||
"var b=atob('$encoded'),u=new Uint8Array(b.length);" +
|
||||
"for(var j=0;j<b.length;j++)u[j]=b.charCodeAt(j);" +
|
||||
"iframe.srcdoc=new TextDecoder('utf-8').decode(u);"
|
||||
} else {
|
||||
// Amethyst's NIP-5A website posture is intentionally unchanged: a dedicated real origin,
|
||||
// normal website storage/network, and NIP-07 behind the existing consent broker.
|
||||
sandbox = "allow-scripts allow-same-origin"
|
||||
frameSource = appOrigin
|
||||
bootstrap = "iframe.src = '$appOrigin/';"
|
||||
}
|
||||
|
||||
val html =
|
||||
shellHtmlBytes
|
||||
.decodeToString()
|
||||
.replace(NappletWebContract.APP_ORIGIN_PLACEHOLDER, appOrigin)
|
||||
.replace(NappletWebContract.APP_SANDBOX_PLACEHOLDER, sandbox)
|
||||
.replace(NappletWebContract.APP_BOOTSTRAP_PLACEHOLDER, bootstrap)
|
||||
.encodeToByteArray()
|
||||
return WebResourceResponse(
|
||||
MIME_HTML,
|
||||
"utf-8",
|
||||
200,
|
||||
"OK",
|
||||
mapOf("Content-Security-Policy" to NappletWebContract.shellCsp(appOrigin)),
|
||||
mapOf("Content-Security-Policy" to NappletWebContract.shellCsp(frameSource)),
|
||||
ByteArrayInputStream(html),
|
||||
)
|
||||
}
|
||||
@@ -183,31 +216,16 @@ class NappletContentServer(
|
||||
)
|
||||
}
|
||||
|
||||
/** Inserts the `window.napplet` client shim into the applet's HTML document. */
|
||||
private fun injectShim(html: ByteArray): ByteArray {
|
||||
val text = html.decodeToString()
|
||||
// Disable the document's overscroll affordance (the bounce/stretch): the Android-level
|
||||
// WebView.overScrollMode only governs the outer frame, so an applet whose own root background is
|
||||
// transparent stretched on over-scroll and exposed the shell's background behind it (a stray
|
||||
// white band at the bottom). `overscroll-behavior: none` removes the stretch at the source,
|
||||
// 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 (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>"
|
||||
val headIdx = text.indexOf("<head", ignoreCase = true)
|
||||
val injected =
|
||||
when {
|
||||
headIdx >= 0 -> {
|
||||
val close = text.indexOf('>', headIdx)
|
||||
if (close >= 0) text.substring(0, close + 1) + script + text.substring(close + 1) else script + text
|
||||
}
|
||||
else -> script + text
|
||||
}
|
||||
return injected.encodeToByteArray()
|
||||
}
|
||||
/** Inserts host policy and the explicit-domain `window.napplet` prelude before authored code. */
|
||||
private fun injectShim(html: ByteArray): ByteArray =
|
||||
NappletWebContract.injectPrelude(
|
||||
html = html,
|
||||
shimJs = shimJs,
|
||||
declaredDomains = declaredDomains,
|
||||
locked = profile == HostProfile.NAPPLET,
|
||||
injectNip07 = profile.injectsNip07,
|
||||
imeProxy = imeProxy,
|
||||
)
|
||||
|
||||
private fun notFound(): WebResourceResponse = WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), ByteArrayInputStream(ByteArray(0)))
|
||||
|
||||
|
||||
+5
-13
@@ -67,7 +67,6 @@ import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities
|
||||
import com.vitorpamplona.amethyst.napplethost.R
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution
|
||||
@@ -243,7 +242,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, profile)
|
||||
contentServer = NappletContentServer(paths, servers, effectiveProxy, cacheDir, shellHtml, shim, appOrigin, profile, declaredDomains)
|
||||
|
||||
// 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
|
||||
@@ -473,10 +472,10 @@ class NappletHostActivity : ComponentActivity() {
|
||||
webViewProfile = intent.getStringExtra(NappletHostContract.EXTRA_WEBVIEW_PROFILE)
|
||||
|
||||
val requires = intent.getStringArrayListExtra(NappletHostContract.EXTRA_REQUIRES) ?: emptyList()
|
||||
val resolved = resolveRequiredCapabilities(requires)
|
||||
// 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()
|
||||
val resolved = profile.declaredCapabilities(requires)
|
||||
// Domain-object presence is the NIP-5D availability signal. The broker still enforces the
|
||||
// authoritative set minted into the launch token in the main process.
|
||||
declaredDomains = resolved.map { it.name.lowercase() }.distinct()
|
||||
|
||||
return author.isNotEmpty() && launchToken.isNotEmpty()
|
||||
}
|
||||
@@ -677,13 +676,6 @@ class NappletHostActivity : ComponentActivity() {
|
||||
// correlate on its id. The broker reads `type` to decode and to build the .result reply.
|
||||
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
|
||||
|
||||
// Shell handshake: the SDK posts `shell.ready` (no id) and answers shell.supports() locally
|
||||
// from the `shell.init` environment we send back here.
|
||||
if (envelope.optString("type") == "shell.ready") {
|
||||
runCatching { replyProxy.postMessage(NappletProtocolJson.encodeShellInit(declaredDomains, declaredDomains)) }
|
||||
return
|
||||
}
|
||||
|
||||
// Unbind a keyboard action as soon as the applet drops it (the broker's Done reply carries no
|
||||
// actionId, so the binding is removed here from the envelope itself).
|
||||
if (envelope.optString("type") == "keys.unregisterAction") {
|
||||
|
||||
+16
-10
@@ -54,8 +54,6 @@ import androidx.webkit.WebMessageCompat
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
@@ -201,7 +199,8 @@ class NappletHostService : Service() {
|
||||
if (author.isEmpty() || launchToken.isEmpty()) return null
|
||||
|
||||
val requires = data.getStringArrayList(NappletHostContract.EXTRA_REQUIRES) ?: emptyList()
|
||||
val declaredDomains = (listOf("shell") + resolveRequiredCapabilities(requires).capabilities.map { it.name.lowercase() }).distinct()
|
||||
val profile = HostProfile.fromName(data.getString(NappletHostContract.EXTRA_HOST_PROFILE))
|
||||
val declaredDomains = profile.declaredCapabilities(requires).map { it.name.lowercase() }.distinct()
|
||||
|
||||
val tab =
|
||||
NappletTab(
|
||||
@@ -212,7 +211,7 @@ class NappletHostService : Service() {
|
||||
author = author,
|
||||
identifier = data.getString(NappletHostContract.EXTRA_IDENTIFIER).orEmpty(),
|
||||
launchToken = launchToken,
|
||||
profile = HostProfile.fromName(data.getString(NappletHostContract.EXTRA_HOST_PROFILE)),
|
||||
profile = 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),
|
||||
@@ -300,7 +299,19 @@ class NappletHostService : Service() {
|
||||
NappletWebViewProfile.apply(context, wv, tab.webViewProfile)
|
||||
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.profile, imeProxy = true)
|
||||
tab.contentServer =
|
||||
NappletContentServer(
|
||||
tab.paths,
|
||||
tab.servers,
|
||||
effectiveProxy,
|
||||
cacheDir,
|
||||
shellHtml,
|
||||
shimJs,
|
||||
appOrigin,
|
||||
tab.profile,
|
||||
tab.declaredDomains,
|
||||
imeProxy = true,
|
||||
)
|
||||
|
||||
hardenWebView(wv, tab)
|
||||
// Theme the pre-load background so the shell/app loading shows Amethyst's background, not white.
|
||||
@@ -468,11 +479,6 @@ class NappletHostService : Service() {
|
||||
val raw = message.data ?: return
|
||||
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
|
||||
|
||||
if (envelope.optString("type") == "shell.ready") {
|
||||
runCatching { replyProxy.postMessage(NappletProtocolJson.encodeShellInit(tab.declaredDomains, tab.declaredDomains)) }
|
||||
return
|
||||
}
|
||||
|
||||
// IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client.
|
||||
if (envelope.optString("type").startsWith("ime.")) {
|
||||
val reply =
|
||||
|
||||
Reference in New Issue
Block a user