mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Address NIP-5D review comments
This commit is contained in:
@@ -320,7 +320,7 @@ class NappletBrokerService : Service() {
|
||||
// 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) {
|
||||
if (requestType == "identity.getPublicKey" && outcome.response is NappletResponse.PublicKey && launchToken != null) {
|
||||
identityWatch.start(launchToken, session.accountPubKey) { push(replyTo, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Streams `identity.changed` pushes to an applet that registered `napplet.identity.onChanged`. It
|
||||
@@ -41,21 +42,24 @@ class NappletIdentityWatch(
|
||||
private val scope: CoroutineScope,
|
||||
private val pubKey: (boundPubKey: String) -> Flow<String>,
|
||||
) {
|
||||
private val jobs = mutableMapOf<String, Job>()
|
||||
private val jobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
fun start(
|
||||
watchId: String,
|
||||
boundPubKey: String,
|
||||
push: (String) -> Unit,
|
||||
) {
|
||||
if (jobs.containsKey(watchId)) return
|
||||
jobs[watchId] =
|
||||
scope.launch {
|
||||
pubKey(boundPubKey)
|
||||
.distinctUntilChanged()
|
||||
.drop(1)
|
||||
.collect { push(NappletProtocolJson.encodeIdentityChanged(it)) }
|
||||
}
|
||||
jobs.computeIfAbsent(watchId) { id ->
|
||||
scope
|
||||
.launch {
|
||||
pubKey(boundPubKey)
|
||||
.distinctUntilChanged()
|
||||
.drop(1)
|
||||
.collect { push(NappletProtocolJson.encodeIdentityChanged(it)) }
|
||||
}.also { job ->
|
||||
job.invokeOnCompletion { jobs.remove(id, job) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopAll() {
|
||||
|
||||
@@ -24,7 +24,6 @@ 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
|
||||
@@ -39,6 +38,7 @@ 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
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* Opens a napplet/nsite in the sandboxed [NappletHostActivity] (the `:napplet` process). Only
|
||||
@@ -56,7 +56,7 @@ object NappletLauncher {
|
||||
) {
|
||||
val event = manifest as? Event
|
||||
if (event?.verify() != true || event.pubKey != authorPubKey) {
|
||||
Log.w(TAG, "Refusing NIP-5D manifest that failed signature/author verification")
|
||||
Log.w(TAG) { "Refusing NIP-5D manifest that failed signature/author verification" }
|
||||
return
|
||||
}
|
||||
buildLaunchParams(context, manifest, authorPubKey, identifier)?.let { openHost(context, it) }
|
||||
@@ -80,7 +80,7 @@ object NappletLauncher {
|
||||
profile: HostProfile,
|
||||
) {
|
||||
if (profile != HostProfile.WEBSITE) {
|
||||
Log.w(TAG, "Refusing raw NIP-5D launch without a verified manifest")
|
||||
Log.w(TAG) { "Refusing raw NIP-5D launch without a verified manifest" }
|
||||
return
|
||||
}
|
||||
val params =
|
||||
@@ -219,7 +219,7 @@ object NappletLauncher {
|
||||
): 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")
|
||||
Log.w(TAG) { "Refusing embedded NIP-5D manifest that failed signature/author verification" }
|
||||
return null
|
||||
}
|
||||
return runCatching {
|
||||
|
||||
+34
-3
@@ -39,16 +39,21 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.sniffContentType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Dns
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.InetAddress
|
||||
import java.net.URLDecoder
|
||||
@@ -127,7 +132,7 @@ class NappletResourceFetcher(
|
||||
)
|
||||
}.build()
|
||||
|
||||
private fun fetchHttps(
|
||||
private suspend fun fetchHttps(
|
||||
url: String,
|
||||
client: OkHttpClient,
|
||||
): NappletResourceResult {
|
||||
@@ -141,7 +146,7 @@ class NappletResourceFetcher(
|
||||
.url(current)
|
||||
.get()
|
||||
.build(),
|
||||
).execute()
|
||||
).await()
|
||||
.use { response ->
|
||||
if (response.isRedirect) {
|
||||
if (hop >= MAX_REDIRECTS) return failure(ERROR_BLOCKED, "Redirect limit exceeded.")
|
||||
@@ -227,7 +232,7 @@ class NappletResourceFetcher(
|
||||
* wrong server can never substitute the blob. Returns null for a malformed hash or if no server
|
||||
* serves it.
|
||||
*/
|
||||
private fun fetchBlossom(
|
||||
private suspend fun fetchBlossom(
|
||||
url: String,
|
||||
client: OkHttpClient,
|
||||
): NappletResourceResult {
|
||||
@@ -257,6 +262,32 @@ class NappletResourceFetcher(
|
||||
return failure(ERROR_NOT_FOUND, "No Blossom server returned the verified blob.")
|
||||
}
|
||||
|
||||
private suspend fun Call.await(): Response =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
continuation.invokeOnCancellation { cancel() }
|
||||
enqueue(
|
||||
object : Callback {
|
||||
override fun onFailure(
|
||||
call: Call,
|
||||
e: IOException,
|
||||
) {
|
||||
if (continuation.isActive) continuation.resumeWith(Result.failure(e))
|
||||
}
|
||||
|
||||
override fun onResponse(
|
||||
call: Call,
|
||||
response: Response,
|
||||
) {
|
||||
if (continuation.isActive) {
|
||||
continuation.resumeWith(Result.success(response))
|
||||
} else {
|
||||
response.close()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Parses a `data:[<mediatype>][;base64],<data>` URL into bytes + content type. */
|
||||
private fun decodeDataUrl(url: String): NappletResourceResult {
|
||||
val comma = url.indexOf(',')
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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 android.content.Context
|
||||
import android.content.Intent
|
||||
import com.vitorpamplona.amethyst.napplethost.HostProfile
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class NappletLauncherTest {
|
||||
private val context = mockk<Context>(relaxed = true)
|
||||
private val manifest = mockk<NappletManifest>()
|
||||
private val author = "aa".repeat(32)
|
||||
|
||||
@Test
|
||||
fun manifestLaunchRejectsNonEventManifest() {
|
||||
NappletLauncher.launch(context, manifest, author, "demo")
|
||||
|
||||
verify(exactly = 0) { context.startActivity(any<Intent>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun embeddedBuildLaunchParamsRejectsNonEventManifest() {
|
||||
assertNull(NappletLauncher.buildLaunchParams(context, manifest, author, "demo"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rawLaunchRejectsNappletProfile() {
|
||||
every { context.startActivity(any<Intent>()) } returns Unit
|
||||
|
||||
NappletLauncher.launch(
|
||||
context = context,
|
||||
paths = emptyList(),
|
||||
servers = emptyList(),
|
||||
authorPubKey = author,
|
||||
identifier = "demo",
|
||||
aggregateHash = null,
|
||||
title = "Demo",
|
||||
requires = emptyList(),
|
||||
profile = HostProfile.NAPPLET,
|
||||
)
|
||||
|
||||
verify(exactly = 0) { context.startActivity(any<Intent>()) }
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionL
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.PermissionDecision
|
||||
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.amethyst.commons.napplet.protocol.toSignerOp
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
@@ -376,11 +377,11 @@ class NappletBroker(
|
||||
|
||||
private fun storageCoordinate(
|
||||
identity: NappletIdentity,
|
||||
scope: com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope,
|
||||
scope: NappletStorageScope,
|
||||
): String =
|
||||
when (scope) {
|
||||
com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope.SHARED -> identity.storageCoordinate
|
||||
com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope.INSTANCE -> identity.instanceStorageCoordinate
|
||||
NappletStorageScope.SHARED -> identity.storageCoordinate
|
||||
NappletStorageScope.INSTANCE -> identity.instanceStorageCoordinate
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+8
-2
@@ -42,6 +42,7 @@ object NappletRequestRouter {
|
||||
/** Send this `.result` payload back, correlated to the request's id. */
|
||||
data class Reply(
|
||||
val payload: String,
|
||||
val response: NappletResponse? = null,
|
||||
) : Outcome
|
||||
|
||||
/** Open a live relay subscription; the host streams `relay.event`/`relay.eose`/`relay.closed` by [subId]. */
|
||||
@@ -114,7 +115,12 @@ object NappletRequestRouter {
|
||||
|
||||
val request =
|
||||
runCatching { NappletProtocolJson.decodeRequest(payload) }.getOrNull()
|
||||
?: return Outcome.Ignore
|
||||
?: return if (runCatching { NappletProtocolJson.readId(payload) }.getOrNull() != null) {
|
||||
val failure = NappletResponse.Failed("Malformed or unsupported request.")
|
||||
Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, failure), failure)
|
||||
} else {
|
||||
Outcome.Ignore
|
||||
}
|
||||
|
||||
val response = broker.handle(identity, request, declared)
|
||||
|
||||
@@ -131,6 +137,6 @@ object NappletRequestRouter {
|
||||
}
|
||||
}
|
||||
|
||||
return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, response))
|
||||
return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, response), response)
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -61,6 +61,9 @@ object NappletProtocolJson {
|
||||
/** The `type` discriminant of a request envelope, used to build the matching `.result` type. */
|
||||
fun readType(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("type")
|
||||
|
||||
/** The request `id`, when the envelope expects a correlated reply. */
|
||||
fun readId(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("id")
|
||||
|
||||
/** 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")
|
||||
|
||||
|
||||
+14
@@ -125,6 +125,20 @@ class NappletRequestRouterTest {
|
||||
assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"relay.publish"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keyedUnknownAndMalformedRequestsReplyWithFailure() =
|
||||
runTest {
|
||||
val unknown = route("""{"type":"totally.unknown","id":"r1"}""")
|
||||
assertIs<NappletRequestRouter.Outcome.Reply>(unknown)
|
||||
assertTrue(unknown.payload.contains("totally.unknown.result"))
|
||||
assertTrue(unknown.payload.contains("Malformed or unsupported request."))
|
||||
|
||||
val malformed = route("""{"type":"relay.publish","id":"r2"}""")
|
||||
assertIs<NappletRequestRouter.Outcome.Reply>(malformed)
|
||||
assertTrue(malformed.payload.contains("relay.publish.result"))
|
||||
assertTrue(malformed.payload.contains("Malformed or unsupported request."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun queryRepliesWithItsResult() =
|
||||
runTest {
|
||||
|
||||
Reference in New Issue
Block a user