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:
Claude
2026-06-22 22:49:58 +00:00
parent 5e4250bfbc
commit c7d876096f
11 changed files with 106 additions and 8 deletions
@@ -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 {
+1
View File
@@ -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'); }
});
}
})();
@@ -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()) {
@@ -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
@@ -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"))
@@ -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 {
@@ -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()
@@ -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.