mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
feat: NIP-07 window.nostr provider for nSites
nSites now open in "website mode": a normal web app with normal network access plus a NIP-07 window.nostr provider, so standard Nostr web apps can "log in with Amethyst" and sign as the active user. Napplets are unchanged (locked, declared-only sandbox). - window.nostr (shim.js) installs only when the host sets __nappletNip07 (website mode). getPublicKey/getRelays reuse the existing consent-gated identity reads; signEvent is a new sign-only op honoring the app-supplied created_at (no publish — the web app sends to relays itself). - NappletRequest.SignEvent + nostr.signEvent decode; broker signs as the user and returns the signed event without publishing. pubkey is still fixed by the signer, so the app can never sign as another identity. - Website mode: content server defers off-origin requests to the WebView and drops the app CSP (normal network); locked napplets keep connect-src 'none' and 404 off-origin. - Launcher grants IDENTITY + RELAY (consent-gated) for website mode, independent of the nSite's empty manifest requires. - Consent dialog shows the kind + content preview for a sign request. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
@@ -63,6 +63,14 @@ class NappletConsentSummary(
|
||||
context.getString(R.string.napplet_consent_publish_preview, request.kind) + "\n“$preview”"
|
||||
}
|
||||
}
|
||||
is NappletRequest.SignEvent -> {
|
||||
val preview = request.content.take(160).trim()
|
||||
if (preview.isEmpty()) {
|
||||
context.getString(R.string.napplet_consent_sign, request.kind)
|
||||
} else {
|
||||
context.getString(R.string.napplet_consent_sign, request.kind) + "\n“$preview”"
|
||||
}
|
||||
}
|
||||
is NappletRequest.PublishEncrypted -> context.getString(R.string.napplet_consent_publish_encrypted)
|
||||
is NappletRequest.QueryEvents, is NappletRequest.Subscribe -> context.getString(R.string.napplet_consent_query)
|
||||
is NappletRequest.StorageGet, is NappletRequest.StorageSet, is NappletRequest.StorageRemove, is NappletRequest.StorageKeys ->
|
||||
|
||||
@@ -71,6 +71,10 @@ object NappletLauncher {
|
||||
aggregateHash: HexKey?,
|
||||
title: String,
|
||||
requires: List<String>,
|
||||
// nSites open in "website mode": a NIP-07 window.nostr provider + normal network. The broker
|
||||
// then grants the IDENTITY + RELAY capabilities NIP-07 needs (consent-gated), regardless of the
|
||||
// (empty) manifest `requires`. Napplets pass false and keep their declared-only, locked sandbox.
|
||||
websiteMode: Boolean = false,
|
||||
) {
|
||||
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
|
||||
|
||||
@@ -86,11 +90,16 @@ object NappletLauncher {
|
||||
// Mint the launch token in the (trusted) main process: the broker resolves the sandbox's
|
||||
// requests back to THIS identity + declared set, regardless of anything the sandbox sends.
|
||||
val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = aggregateHash)
|
||||
val declared = resolveRequiredCapabilities(requires).capabilities.toSet()
|
||||
val declared =
|
||||
if (websiteMode) {
|
||||
setOf(NappletCapability.IDENTITY, NappletCapability.RELAY)
|
||||
} else {
|
||||
resolveRequiredCapabilities(requires).capabilities.toSet()
|
||||
}
|
||||
val launchToken = NappletLaunchRegistry.register(identity, declared)
|
||||
|
||||
// Resolve capability labels here (the app has the resources) so the sandbox module needs none.
|
||||
val capLabels = requires.mapNotNull { NappletCapability.fromNapDomain(it) }.map { context.getString(it.labelRes()) }
|
||||
val capLabels = declared.map { context.getString(it.labelRes()) }
|
||||
|
||||
val intent =
|
||||
Intent(context, NappletHostActivity::class.java).apply {
|
||||
@@ -105,6 +114,7 @@ object NappletLauncher {
|
||||
putExtra(NappletHostContract.EXTRA_CAP_LABELS, ArrayList(capLabels))
|
||||
putExtra(NappletHostContract.EXTRA_LAUNCH_TOKEN, launchToken)
|
||||
putExtra(NappletHostContract.EXTRA_PROXY_PORT, proxyPort)
|
||||
putExtra(NappletHostContract.EXTRA_WEBSITE_MODE, websiteMode)
|
||||
if (context !is android.app.Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
|
||||
@@ -126,6 +126,7 @@ fun RenderRootSiteEvent(
|
||||
aggregateHash = null,
|
||||
title = event.title() ?: "nsite",
|
||||
requires = emptyList(),
|
||||
websiteMode = true,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -165,6 +166,7 @@ fun RenderNamedSiteEvent(
|
||||
aggregateHash = null,
|
||||
title = event.title() ?: event.identifier(),
|
||||
requires = emptyList(),
|
||||
websiteMode = true,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -710,6 +710,7 @@
|
||||
<string name="napplet_consent_pay">This nApplet wants to pay a Lightning invoice.</string>
|
||||
<string name="napplet_consent_resource">This nApplet wants to fetch a web resource.</string>
|
||||
<string name="napplet_consent_upload">This nApplet wants to upload a file to your media server.</string>
|
||||
<string name="napplet_consent_sign">This site wants to sign a kind %1$d event with your Nostr key.</string>
|
||||
<plurals name="napplet_consent_pay_amount">
|
||||
<item quantity="one">This nApplet wants to pay a Lightning invoice for %1$d sat.</item>
|
||||
<item quantity="other">This nApplet wants to pay a Lightning invoice for %1$d sats.</item>
|
||||
|
||||
@@ -157,4 +157,16 @@
|
||||
}
|
||||
};
|
||||
window.napplet = Object.freeze(napplet);
|
||||
|
||||
// NIP-07 provider (window.nostr), installed only for nSites in website mode (the host sets
|
||||
// window.__nappletNip07 synchronously before this shim). Lets standard Nostr web apps "log in with
|
||||
// Amethyst" and sign, bridged to the same consent-gated signer: getPublicKey + getRelays reuse the
|
||||
// identity reads; signEvent is sign-only (no publish) and honors the app's created_at.
|
||||
if (window.__nappletNip07 && !window.nostr) {
|
||||
window.nostr = Object.freeze({
|
||||
getPublicKey: function(){ return field(call('identity.getPublicKey'), 'pubkey'); },
|
||||
getRelays: function(){ return field(call('identity.getRelays'), 'relays'); },
|
||||
signEvent: function(event){ return field(call('nostr.signEvent', { event: event }), 'event'); }
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
+4
@@ -188,6 +188,10 @@ class NappletBroker(
|
||||
// created_at comes from the host, never the applet, so it cannot backdate.
|
||||
is NappletRequest.Publish -> signAndPublish(request.kind, request.tags, request.content)
|
||||
|
||||
// NIP-07 signEvent: sign as the user (honoring the app's created_at) and return it WITHOUT
|
||||
// publishing — the web app sends it to relays itself. pubkey is still fixed by the signer.
|
||||
is NappletRequest.SignEvent -> NappletResponse.Published(signer.sign(request.createdAt, request.kind, request.tags, request.content), emptyList())
|
||||
|
||||
is NappletRequest.PublishEncrypted -> {
|
||||
val ciphertext =
|
||||
when (request.encryption.trim().lowercase()) {
|
||||
|
||||
+33
@@ -103,6 +103,39 @@ sealed interface NappletRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-07 `window.nostr.signEvent`: sign a full event template **as the user** and return it,
|
||||
* **without publishing** (the web app sends it to relays itself). Unlike [Publish] this honors the
|
||||
* app-supplied `created_at` (NIP-07 apps rely on it); the shell still fixes `pubkey` to the real
|
||||
* signer, so the app can never sign as another identity. Exposed only to nSites (website mode).
|
||||
*/
|
||||
data class SignEvent(
|
||||
val kind: Int,
|
||||
val tags: Array<Array<String>>,
|
||||
val content: String,
|
||||
val createdAt: Long,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.RELAY
|
||||
override val signsAsUser get() = true
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is SignEvent) return false
|
||||
if (kind != other.kind || createdAt != other.createdAt || content != other.content) return false
|
||||
if (tags.size != other.tags.size) return false
|
||||
for (i in tags.indices) if (!tags[i].contentEquals(other.tags[i])) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = kind
|
||||
result = 31 * result + createdAt.hashCode()
|
||||
result = 31 * result + content.hashCode()
|
||||
result = 31 * result + tags.sumOf { it.contentHashCode() }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt [content] to [recipient] with [encryption] (`"nip44"`, default, or `"nip04"`), then
|
||||
* build, sign, and publish the event. The shell holds the key and performs both the encryption
|
||||
|
||||
+10
@@ -148,6 +148,16 @@ object NappletProtocolJson {
|
||||
}
|
||||
"relay.query" -> NappletRequest.QueryEvents(decodeFilterList(o))
|
||||
"relay.subscribe" -> NappletRequest.Subscribe(decodeFilterList(o))
|
||||
"nostr.signEvent" -> {
|
||||
// NIP-07 signEvent: sign-only, honoring the app-supplied created_at (fallback: now).
|
||||
val t = o.eventTemplate()
|
||||
NappletRequest.SignEvent(
|
||||
kind = t.kindOf(),
|
||||
tags = decodeTags(t),
|
||||
content = t.str("content") ?: "",
|
||||
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"))
|
||||
|
||||
+13
-5
@@ -57,6 +57,9 @@ class NappletContentServer(
|
||||
// The applet's own per-applet origin (a distinct napplet.local subdomain). The shell is on
|
||||
// NappletWebContract.ORIGIN; app blobs are served here so the applet has a real, isolated origin.
|
||||
private val appOrigin: String,
|
||||
// nSite "website mode": the applet is a normal web app — it gets a NIP-07 window.nostr provider,
|
||||
// normal network (no app CSP; off-origin requests defer to the WebView), unlike a locked napplet.
|
||||
private val websiteMode: Boolean = false,
|
||||
) {
|
||||
private val cache = NappletBlobCache(NappletBlobCache.dirFor(cacheDir))
|
||||
private val http = NappletBlobHttp.client(proxyPort)
|
||||
@@ -104,7 +107,9 @@ class NappletContentServer(
|
||||
val acceptsHtml = request.requestHeaders["Accept"]?.contains("text/html", ignoreCase = true) == true
|
||||
return serveAppResource(url, acceptsHtml)
|
||||
}
|
||||
return notFound()
|
||||
// Off-origin: a locked napplet 404s (connect-src 'none' means it shouldn't ask). An nSite in
|
||||
// website mode is a normal web app — defer to the WebView so it can load external resources.
|
||||
return if (websiteMode) null else notFound()
|
||||
}
|
||||
|
||||
private fun serveShell(): WebResourceResponse {
|
||||
@@ -148,14 +153,15 @@ class NappletContentServer(
|
||||
val isHtml = mime.equals("text/html", ignoreCase = true)
|
||||
val bytes = if (isHtml) injectShim(resolution.bytes) else resolution.bytes
|
||||
|
||||
// No CORS header needed: the applet document and these blobs are now on the same (per-applet)
|
||||
// origin, so its own module scripts / stylesheets / assets load as same-origin requests.
|
||||
// Locked napplets get the strict app CSP (connect-src 'none', etc.). An nSite in website mode
|
||||
// is a normal web app: no app CSP, so it can talk to relays (wss) and load external resources.
|
||||
val headers = if (websiteMode) emptyMap() else mapOf("Content-Security-Policy" to NappletWebContract.APP_CSP)
|
||||
return WebResourceResponse(
|
||||
mime,
|
||||
charset,
|
||||
200,
|
||||
"OK",
|
||||
mapOf("Content-Security-Policy" to NappletWebContract.APP_CSP),
|
||||
headers,
|
||||
ByteArrayInputStream(bytes),
|
||||
)
|
||||
}
|
||||
@@ -163,7 +169,9 @@ class NappletContentServer(
|
||||
/** Inserts the `window.napplet` client shim into the applet's HTML document. */
|
||||
private fun injectShim(html: ByteArray): ByteArray {
|
||||
val text = html.decodeToString()
|
||||
val script = "<script>$shimJs</script>"
|
||||
// In website mode, set the NIP-07 flag synchronously *before* the shim so window.nostr installs.
|
||||
val nip07Flag = if (websiteMode) "<script>window.__nappletNip07=true;</script>" else ""
|
||||
val script = "$nip07Flag<script>$shimJs</script>"
|
||||
val headIdx = text.indexOf("<head", ignoreCase = true)
|
||||
val injected =
|
||||
when {
|
||||
|
||||
+5
-1
@@ -103,6 +103,9 @@ class NappletHostActivity : ComponentActivity() {
|
||||
// The sandbox never carries its own coordinate, so a compromised :napplet process can't forge one.
|
||||
private var launchToken: String = ""
|
||||
|
||||
// nSite "website mode": a normal web app (NIP-07 window.nostr + normal network), vs a locked napplet.
|
||||
private var websiteMode: Boolean = false
|
||||
|
||||
// NAP domain strings the shell advertises to the applet in the shell.init handshake.
|
||||
private var declaredDomains: List<String> = emptyList()
|
||||
|
||||
@@ -192,7 +195,7 @@ class NappletHostActivity : ComponentActivity() {
|
||||
val shellHtml = readContractAsset(NappletWebContract.SHELL_HTML_PATH)
|
||||
val shim = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString()
|
||||
val appOrigin = NappletWebContract.appOrigin(deriveAppId(author, identifier))
|
||||
contentServer = NappletContentServer(paths, servers, proxyPort, cacheDir, shellHtml, shim, appOrigin)
|
||||
contentServer = NappletContentServer(paths, servers, proxyPort, cacheDir, shellHtml, shim, appOrigin, websiteMode)
|
||||
|
||||
// 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
|
||||
@@ -317,6 +320,7 @@ class NappletHostActivity : ComponentActivity() {
|
||||
servers.addAll(intent.getStringArrayListExtra(NappletHostContract.EXTRA_SERVERS) ?: emptyList())
|
||||
author = intent.getStringExtra(NappletHostContract.EXTRA_AUTHOR).orEmpty()
|
||||
identifier = intent.getStringExtra(NappletHostContract.EXTRA_IDENTIFIER).orEmpty()
|
||||
websiteMode = intent.getBooleanExtra(NappletHostContract.EXTRA_WEBSITE_MODE, false)
|
||||
title = intent.getStringExtra(NappletHostContract.EXTRA_TITLE).orEmpty()
|
||||
proxyPort = intent.getIntExtra(NappletHostContract.EXTRA_PROXY_PORT, -1)
|
||||
launchToken = intent.getStringExtra(NappletHostContract.EXTRA_LAUNCH_TOKEN).orEmpty()
|
||||
|
||||
+6
@@ -53,6 +53,12 @@ object NappletHostContract {
|
||||
/** SOCKS proxy port to route blob fetches through, or -1 for a direct connection. */
|
||||
const val EXTRA_PROXY_PORT = "napplet_proxy_port"
|
||||
|
||||
/**
|
||||
* nSite "website mode": treat the content as a normal web app — install the NIP-07 `window.nostr`
|
||||
* provider and allow normal network (no app CSP). Off for locked napplets.
|
||||
*/
|
||||
const val EXTRA_WEBSITE_MODE = "napplet_website_mode"
|
||||
|
||||
/**
|
||||
* FQN of the main-process broker service (in `:amethyst`). The sandbox binds it by name so it
|
||||
* needs no compile-time reference to `:amethyst`. Must match the manifest `<service>` declaration.
|
||||
|
||||
Reference in New Issue
Block a user