diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt
index 5a77e2a369..de5815af52 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt
@@ -277,7 +277,7 @@ class NappletBrokerService : Service() {
// Bind to the account active at mint time: a browser token minted for one account must
// never sign as another if the user switches while the page is still open.
val mintAccount = Amethyst.instance.sessionManager.loggedInAccount() ?: return true
- val token = NappletLaunchRegistry.register(identity, setOf(NappletCapability.IDENTITY, NappletCapability.RELAY), mintAccount.pubKey)
+ val token = NappletLaunchRegistry.register(identity, NappletCapability.WEBSITE_CAPABILITIES, mintAccount.pubKey)
val response =
Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply {
this.data =
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt
index 15ab1abd71..5e988ce809 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt
@@ -31,6 +31,7 @@ fun NappletCapability.labelRes(): Int =
NappletCapability.IDENTITY -> R.string.napplet_cap_identity
NappletCapability.KEYS -> R.string.napplet_cap_keys
NappletCapability.RELAY -> R.string.napplet_cap_relay
+ NappletCapability.SIGNER -> R.string.napplet_cap_signer
NappletCapability.STORAGE -> R.string.napplet_cap_storage
NappletCapability.VALUE -> R.string.napplet_cap_value
NappletCapability.RESOURCE -> R.string.napplet_cap_resource
@@ -47,6 +48,7 @@ fun NappletCapability.descriptionRes(): Int =
NappletCapability.IDENTITY -> R.string.napplet_cap_identity_desc
NappletCapability.KEYS -> R.string.napplet_cap_keys_desc
NappletCapability.RELAY -> R.string.napplet_cap_relay_desc
+ NappletCapability.SIGNER -> R.string.napplet_cap_signer_desc
NappletCapability.STORAGE -> R.string.napplet_cap_storage_desc
NappletCapability.VALUE -> R.string.napplet_cap_value_desc
NappletCapability.RESOURCE -> R.string.napplet_cap_resource_desc
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt
index 583cda40b4..6b520ba30a 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt
@@ -275,6 +275,14 @@ class NappletConsentSummary(
}
}
is NappletRequest.PublishEncrypted -> context.getString(R.string.napplet_consent_publish_encrypted)
+ is NappletRequest.Nip44Encrypt -> {
+ val preview = request.plaintext.take(160).trim()
+ val summary = context.getString(R.string.napplet_consent_nip44_encrypt, counterpartyLabel(request.peer))
+ if (preview.isEmpty()) summary else summary + "\n\u201C$preview\u201D"
+ }
+ // The ciphertext is meaningless to show, so name the counterparty instead — that is
+ // the decision the user is actually making ("let this page read messages from X").
+ is NappletRequest.Nip44Decrypt -> context.getString(R.string.napplet_consent_nip44_decrypt, counterpartyLabel(request.peer))
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 ->
context.getString(R.string.napplet_consent_storage)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt
index 3472835691..e53b9aceac 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt
@@ -27,6 +27,8 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
+import com.vitorpamplona.amethyst.commons.napplet.protocol.counterpartyPubKey
+import com.vitorpamplona.amethyst.commons.napplet.protocol.toNarrowSignerOp
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
@@ -78,12 +80,22 @@ fun buildSignerConsentInfo(
} else {
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
}
- val summary = op.label(context)
+ // A decrypt grant can be scoped to one conversation: offer "always allow for Alice" next to the
+ // broad "always allow", instead of only the all-conversations-forever choice. Mirrors the NIP-46
+ // dialog, so the same decision reads the same way whichever surface asked.
+ val narrowOp = request.toNarrowSignerOp()
+ val counterparty = request.counterpartyPubKey()
+ // For decrypt this names the counterparty ("read your private messages with Alice").
+ val summary = (narrowOp ?: op).label(context)
val preview =
when (request) {
is NappletRequest.Publish -> request.content.take(160).trim()
is NappletRequest.SignEvent -> request.content.take(160).trim()
is NappletRequest.PublishEncrypted -> request.content.take(160).trim()
+ // Encryption shows the plaintext the page wants sealed; decryption has only ciphertext,
+ // which tells the user nothing, so its preview stays empty and the counterparty in
+ // rawData carries the meaning.
+ is NappletRequest.Nip44Encrypt -> request.plaintext.take(160).trim()
else -> ""
}
val rawData =
@@ -105,6 +117,20 @@ fun buildSignerConsentInfo(
node.put("content", request.content)
JacksonMapper.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node)
}
+ is NappletRequest.Nip44Encrypt -> {
+ val node = JacksonMapper.mapper.createObjectNode()
+ node.put("operation", "nip44.encrypt")
+ node.put("peer", request.peer)
+ node.put("plaintext", request.plaintext)
+ JacksonMapper.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node)
+ }
+ is NappletRequest.Nip44Decrypt -> {
+ val node = JacksonMapper.mapper.createObjectNode()
+ node.put("operation", "nip44.decrypt")
+ node.put("peer", request.peer)
+ node.put("ciphertext", request.ciphertext)
+ JacksonMapper.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node)
+ }
else -> ""
}
val previewTemplate =
@@ -122,6 +148,16 @@ fun buildSignerConsentInfo(
rawData = rawData,
iconUrl = iconUrl,
previewTemplate = previewTemplate,
+ counterpartyName = counterparty?.let { counterpartyLabel(it) },
+ counterpartyPicture = counterparty?.let { LocalCache.getUserIfExists(it)?.profilePicture() },
+ counterpartyPubKey = counterparty,
+ narrowOp = narrowOp,
+ // Read the pubkey off the narrow op itself: the dialog drops the button unless BOTH halves
+ // are present, so deriving them from one value keeps them from disagreeing.
+ narrowOpLabel =
+ (narrowOp as? NostrSignerOp.DecryptFrom)?.let {
+ context.getString(R.string.nip46_signer_allow_always_for, counterpartyLabel(it.counterparty))
+ },
)
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt
index 201c818aa3..c6f554e7b3 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt
@@ -29,6 +29,7 @@ internal fun NappletCapability.symbol(): MaterialSymbol =
NappletCapability.IDENTITY -> MaterialSymbols.AccountCircle
NappletCapability.KEYS -> MaterialSymbols.Key
NappletCapability.RELAY -> MaterialSymbols.Public
+ NappletCapability.SIGNER -> MaterialSymbols.Lock
NappletCapability.STORAGE -> MaterialSymbols.Storage
NappletCapability.VALUE -> MaterialSymbols.Bolt
NappletCapability.RESOURCE -> MaterialSymbols.Language
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index da24c209c9..f4e2d47349 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -401,6 +401,7 @@
Identity
Keyboard actions
Relays
+ Encryption
Storage
Payments
Network
@@ -409,6 +410,7 @@
Read your public key
Bind keyboard shortcuts
Read, and sign & publish your events
+ Encrypt and decrypt private messages with your key
Its own private storage
Pay Lightning invoices
Fetch web and Blossom resources
@@ -426,6 +428,8 @@
This nApplet wants to sign and publish a kind %1$d event as you.
This nApplet wants to sign and publish a kind %1$d event as you:
This nApplet wants to send an encrypted event as you.
+ This site wants to encrypt a message to %1$s with your Nostr key.
+ This site wants to decrypt a message from %1$s with your Nostr key.
This nApplet wants to read events from your relays.
This nApplet wants to use its private storage.
This nApplet wants to pay a Lightning invoice.
diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt
index 211ea1a7be..1f2422de4b 100644
--- a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt
+++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt
@@ -368,4 +368,33 @@ class NappletProtocolJsonTest {
val failed = json.parseToJsonElement(NappletProtocolJson.encodeResponse("relay.publish", NappletResponse.Failed("boom"))).jsonObject
assertEquals("boom", failed["reason"]?.jsonPrimitive?.content)
}
+
+ @Test
+ fun decodesNip44EncryptAndDecrypt() {
+ // The envelope the injected window.nostr.nip44 shim posts. Field names are the contract
+ // between shim.js and this decoder — renaming either side silently breaks NIP-17 in nSites.
+ assertEquals(
+ NappletRequest.Nip44Encrypt("pk", "gm"),
+ NappletProtocolJson.decodeRequest("""{"type":"nostr.nip44Encrypt","id":"1","peer":"pk","plaintext":"gm"}"""),
+ )
+ assertEquals(
+ NappletRequest.Nip44Decrypt("pk", "cipher"),
+ NappletProtocolJson.decodeRequest("""{"type":"nostr.nip44Decrypt","id":"1","peer":"pk","ciphertext":"cipher"}"""),
+ )
+ }
+
+ @Test
+ fun nip44WithoutAPeerIsRejectedRatherThanDefaultedToSomeKey() {
+ // `peer` is required: silently substituting a default would encrypt to the wrong party.
+ assertThrowsAny { NappletProtocolJson.decodeRequest("""{"type":"nostr.nip44Encrypt","id":"1","plaintext":"gm"}""") }
+ assertThrowsAny { NappletProtocolJson.decodeRequest("""{"type":"nostr.nip44Decrypt","id":"1","ciphertext":"c"}""") }
+ }
+
+ @Test
+ fun encodesTextResultsUnderValue() {
+ val result = json.parseToJsonElement(NappletProtocolJson.encodeResponse("nostr.nip44Encrypt", NappletResponse.Text("CIPHER"))).jsonObject
+ assertEquals("nostr.nip44Encrypt.result", result["type"]?.jsonPrimitive?.content)
+ assertTrue(result["ok"]!!.jsonPrimitive.boolean)
+ assertEquals("CIPHER", result["value"]?.jsonPrimitive?.content)
+ }
}
diff --git a/commons/src/commonMain/composeResources/files/napplet/shim.js b/commons/src/commonMain/composeResources/files/napplet/shim.js
index f36c48280b..1d91fae295 100644
--- a/commons/src/commonMain/composeResources/files/napplet/shim.js
+++ b/commons/src/commonMain/composeResources/files/napplet/shim.js
@@ -812,11 +812,20 @@
// 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.
+ // nip44 is optional in NIP-07 but not optional in practice: a kind:13 seal is NIP-44 ciphertext
+ // authored by the real key, so without it a page can sign yet cannot build a NIP-59 gift wrap —
+ // NIP-17 DMs and every gift-wrapped app protocol are simply unreachable. The shell does the crypto
+ // and returns only the result; the key never enters the page. nip04 stays deliberately absent
+ // (deprecated, and nothing that still needs it should be encouraged).
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'); }
+ signEvent: function(event){ return field(call('nostr.signEvent', { event: event }), 'event'); },
+ nip44: Object.freeze({
+ encrypt: function(peer, plaintext){ return field(call('nostr.nip44Encrypt', { peer: peer, plaintext: String(plaintext) }), 'value'); },
+ decrypt: function(peer, ciphertext){ return field(call('nostr.nip44Decrypt', { peer: peer, ciphertext: String(ciphertext) }), 'value'); }
+ })
});
}
})();
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt
index b7f9adf73d..31f5bea4af 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt
@@ -34,6 +34,7 @@ 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.toNarrowSignerOp
import com.vitorpamplona.amethyst.commons.napplet.protocol.toSignerOp
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -245,6 +246,12 @@ class NappletBroker(
signAndPublish(request.kind, withRecipientTag(request.tags, request.recipient), ciphertext)
}
+ // NIP-07 nip44.encrypt/decrypt: the shell runs the crypto with the real key and hands back
+ // only the result, so the page can build its own NIP-59 seals without ever seeing the key.
+ is NappletRequest.Nip44Encrypt -> NappletResponse.Text(signer.nip44Encrypt(request.plaintext, request.peer))
+
+ is NappletRequest.Nip44Decrypt -> NappletResponse.Text(signer.nip44Decrypt(request.ciphertext, request.peer))
+
is NappletRequest.QueryEvents -> {
val gateway = relay ?: return NappletResponse.Unsupported("relay.query")
NappletResponse.Events(gateway.query(request.filters))
@@ -449,51 +456,58 @@ class NappletBroker(
): Boolean =
signerConsentLock.withLock {
val sl = signerLedger ?: return@withLock true
+ val coordinate = signerCoordinateFor(identity)
+ // A decrypt request also carries a narrower op ("decrypt messages from THIS
+ // counterparty"). A standing narrow grant satisfies it without widening the broad one.
+ val narrowOp = request.toNarrowSignerOp()
+
// Session grants win immediately without touching storage. Scoped to this applet: a
// grant made for one app never authorizes another.
- if (sessionKey(signerCoordinateFor(identity), op) in sessionAllows) {
- sl.updateLastUsed(signerCoordinateFor(identity))
+ if (sessionKey(coordinate, op) in sessionAllows) {
+ sl.updateLastUsed(coordinate)
return@withLock true
}
- when (sl.decide(signerCoordinateFor(identity), op)) {
+ when (sl.decide(coordinate, op)) {
NostrOpDecision.ALLOW -> {
- sl.updateLastUsed(signerCoordinateFor(identity))
+ sl.updateLastUsed(coordinate)
true
}
+ // An explicit DENY on the broad op is final — a narrow grant never overrides it.
NostrOpDecision.DENY -> false
NostrOpDecision.ASK -> {
- val prompt = signerConsentPrompt ?: return@withLock true
- when (val grant = prompt.request(identity, op, request)) {
- is SignerOpGrant.AllowAll -> {
- sl.setPolicy(signerCoordinateFor(identity), AppSignerPolicy.FULL_TRUST)
- sl.updateLastUsed(signerCoordinateFor(identity))
- true
- }
- is SignerOpGrant.AllowForOp -> {
- sl.setOpDecision(signerCoordinateFor(identity), op, NostrOpDecision.ALLOW)
- sl.updateLastUsed(signerCoordinateFor(identity))
- true
- }
- is SignerOpGrant.AllowForSession -> {
- sessionAllows.add(sessionKey(signerCoordinateFor(identity), op))
- sl.updateLastUsed(signerCoordinateFor(identity))
- true
- }
- is SignerOpGrant.AllowUntil -> {
- sl.setTimedOpDecision(signerCoordinateFor(identity), op, NostrOpDecision.ALLOW, grant.expiresAt)
- sl.updateLastUsed(signerCoordinateFor(identity))
- true
- }
- is SignerOpGrant.DenyForOp -> {
- sl.setOpDecision(signerCoordinateFor(identity), op, NostrOpDecision.DENY)
- false
- }
- else -> grant.isAllowed
+ if (narrowOp != null && isNarrowAllowed(sl, coordinate, narrowOp)) {
+ sl.updateLastUsed(coordinate)
+ return@withLock true
}
+ val prompt = signerConsentPrompt ?: return@withLock true
+ val grant = prompt.request(identity, op, request)
+ // Record the GRANT's own op, never the requested one: the dialog may hand back a
+ // narrower op ("only from this counterparty"), and a stored grant must never be
+ // wider than what the user actually tapped.
+ sl.record(coordinate, grant)
+ if (grant is SignerOpGrant.AllowForSession) {
+ sessionAllows.add(sessionKey(coordinate, grant.op))
+ }
+ if (grant.isAllowed) sl.updateLastUsed(coordinate)
+ grant.isAllowed
}
}
}
+ /**
+ * True when a standing or session grant exists for the narrower [narrowOp] (e.g. decrypt-from-X).
+ * Only an explicit per-op override counts: [NostrSignerPermissionLedger.decide] would otherwise
+ * fall through to the app's policy, and FULL_TRUST/REASONABLE would answer for an op nobody ever
+ * granted. Mirrors the NIP-46 authorizer so both surfaces honour a narrow grant identically.
+ */
+ private suspend fun isNarrowAllowed(
+ sl: NostrSignerPermissionLedger,
+ coordinate: String,
+ narrowOp: NostrSignerOp,
+ ): Boolean =
+ sessionKey(coordinate, narrowOp) in sessionAllows ||
+ sl.store.loadOpDecision(coordinate, narrowOp)?.let { sl.decide(coordinate, narrowOp) == NostrOpDecision.ALLOW } ?: false
+
/**
* The signer-ledger coordinate for [identity] under the current account. The signer permission
* store is shared with NIP-46, which already namespaces by account
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt
index 33ee94cf7c..ad26a962ce 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt
@@ -44,6 +44,21 @@ enum class NappletCapability {
/** `relay` — publish (shell-signed), query, and subscribe to the user's relays. */
RELAY,
+ /**
+ * NIP-44 encrypt/decrypt with the user's key, returning the ciphertext/plaintext to the caller
+ * instead of publishing it. Needed by any standard Nostr web app that builds NIP-59 seals
+ * itself (NIP-17 DMs, gift-wrapped app protocols) — signing alone cannot produce a seal.
+ *
+ * Deliberately **not** in [fromNapDomain]: no NIP-5D domain maps here, so a locked napplet can
+ * never declare it. It is granted only to the website posture (the NIP-07 `window.nostr`
+ * surface), where the page is already trusted with `signEvent`. Every individual call still
+ * passes the per-operation signer ledger
+ * ([Encrypt][com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp.Encrypt] /
+ * [Decrypt][com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp.Decrypt]),
+ * which is what actually keeps decryption behind a prompt.
+ */
+ SIGNER,
+
/** `storage` — a per-applet sandboxed key-value store, namespaced by applet identity. */
STORAGE,
@@ -92,6 +107,18 @@ enum class NappletCapability {
get() = !requiresPerUseConsent
companion object {
+ /**
+ * What a page in the **website** posture (the NIP-07 `window.nostr` surface) may ask the
+ * broker for. There are two independent mints of this set — the nSite host derives it from
+ * `HostProfile.WEBSITE`, while the in-app browser mints a fresh per-origin token — so it
+ * lives here, once: when the two drifted, the browser silently denied every call to a
+ * capability the injected shim was still advertising.
+ *
+ * Widening this widens what any visited site can request, so it is a security decision, not
+ * a convenience list.
+ */
+ val WEBSITE_CAPABILITIES: Set = setOf(IDENTITY, RELAY, SIGNER)
+
/**
* 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
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt
index 25a58bca43..385a28596a 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt
@@ -175,6 +175,41 @@ sealed interface NappletRequest {
}
}
+ /**
+ * NIP-07 `window.nostr.nip44.encrypt`: NIP-44 encrypt [plaintext] to [peer] with the user's key
+ * and return the ciphertext **without publishing it**. This is what lets a web app build its own
+ * NIP-59 seals (a kind:13 seal is NIP-44 ciphertext authored by the real key, so `signEvent`
+ * alone cannot produce one) — NIP-17 DMs and every gift-wrapped app protocol need it.
+ *
+ * The page never touches the key: the shell encrypts and hands back only the result.
+ *
+ * [signsAsUser] stays false — this produces no event and no signature; the gate that matters is
+ * the [NostrSignerOp][com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp]
+ * mapping in `toSignerOp`.
+ */
+ data class Nip44Encrypt(
+ val peer: HexKey,
+ val plaintext: String,
+ ) : NappletRequest {
+ override val capability get() = NappletCapability.SIGNER
+ }
+
+ /**
+ * NIP-07 `window.nostr.nip44.decrypt`: NIP-44 decrypt [ciphertext] from [peer] with the user's
+ * key and return the plaintext. The counterpart of [Nip44Encrypt] — a NIP-17 client needs it to
+ * open incoming seals.
+ *
+ * Strictly more dangerous than encryption (it reads, rather than writes, private content), so it
+ * maps to [NostrSignerOp.Decrypt][com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp.Decrypt],
+ * which always asks under the REASONABLE policy instead of auto-approving.
+ */
+ data class Nip44Decrypt(
+ val peer: HexKey,
+ val ciphertext: String,
+ ) : NappletRequest {
+ override val capability get() = NappletCapability.SIGNER
+ }
+
/** Read events matching [filters] (from the cache and/or a bounded relay fetch). */
data class QueryEvents(
val filters: List,
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt
index 905f137517..34821e119b 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt
@@ -34,5 +34,38 @@ fun NappletRequest.toSignerOp(): NostrSignerOp? =
is NappletRequest.Publish -> NostrSignerOp.SignKind(kind)
is NappletRequest.SignEvent -> NostrSignerOp.SignKind(kind)
is NappletRequest.PublishEncrypted -> NostrSignerOp.Encrypt
+ is NappletRequest.Nip44Encrypt -> NostrSignerOp.Encrypt
+ // The broad grant, matching what NIP-46's nip44_decrypt maps to. The narrower
+ // DecryptFrom(peer) exists, but only the NIP-46 authorizer honours it today; recording one
+ // here would be a grant this broker never reads back, so the user would re-prompt forever.
+ is NappletRequest.Nip44Decrypt -> NostrSignerOp.Decrypt
+ else -> null
+ }
+
+/**
+ * The NARROWER op a request may alternatively be granted — today only
+ * [NostrSignerOp.DecryptFrom], i.e. "always allow, but only for this counterparty". Mirrors the
+ * NIP-46 authorizer's `toNarrowSignerOp`.
+ *
+ * A single broad "always allow decrypt" hands an app every private conversation the user will ever
+ * have; this is the granular alternative the consent dialog offers alongside it. `null` for every
+ * request without a counterparty — signing and encryption already name the thing being granted.
+ */
+fun NappletRequest.toNarrowSignerOp(): NostrSignerOp? =
+ when (this) {
+ is NappletRequest.Nip44Decrypt -> NostrSignerOp.DecryptFrom(peer)
+ else -> null
+ }
+
+/**
+ * The counterparty whose conversation a decrypt request asks to read, or `null` for every other
+ * request. Scoped to decryption to match the NIP-46 authorizer and what the consent dialog
+ * documents: it drives "X wants to read your messages with Alice", a categorically different
+ * decision from the encrypt/sign case, where the counterparty is already part of what the user
+ * is composing.
+ */
+fun NappletRequest.counterpartyPubKey(): String? =
+ when (this) {
+ is NappletRequest.Nip44Decrypt -> peer
else -> null
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt
index 07f0394b0c..f87cff6438 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt
@@ -65,6 +65,14 @@ sealed interface NappletResponse {
val value: String?,
) : NappletResponse
+ /**
+ * A single opaque string result, returned in the `value` field. Used by the NIP-44 ops, where the
+ * payload is ciphertext one way and plaintext the other and the host has nothing to interpret.
+ */
+ data class Text(
+ val value: String,
+ ) : NappletResponse
+
/** Result of `storage.keys` (and other string-list reads). */
data class Strings(
val values: List,
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt
index fc59bb7f87..a5d33e5a23 100644
--- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt
@@ -745,4 +745,207 @@ class NappletBrokerTest {
assertIs(tooLarge)
assertEquals("too-large", tooLarge.error)
}
+
+ // ---- NIP-44 (window.nostr.nip44) ------------------------------------------------------------
+ //
+ // The gap these close: a page could sign but not encrypt, so it could not build a kind:13 seal
+ // and every NIP-59 / NIP-17 flow was unreachable through the in-app browser.
+
+ @Test
+ fun nip44RoundTripsThroughTheBrokerWithoutExposingTheKey() =
+ runTest {
+ val peer = NostrSignerInternal(KeyPair("11".repeat(32).hexToByteArray()))
+ val broker = broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS))
+
+ val encrypted =
+ broker.handle(applet, NappletRequest.Nip44Encrypt(peer.pubKey, "gm from the nsite"), allDeclared)
+ assertIs(encrypted)
+
+ // The peer decrypts it with its own key: proof this is real NIP-44 to that pubkey and
+ // not some shell-local encoding.
+ assertEquals("gm from the nsite", peer.nip44Decrypt(encrypted.value, signer.pubKey))
+
+ // ...and back the other way, which is what an inbound seal needs.
+ val sealed = peer.nip44Encrypt("hello back", signer.pubKey)
+ val decrypted = broker.handle(applet, NappletRequest.Nip44Decrypt(peer.pubKey, sealed), allDeclared)
+ assertIs(decrypted)
+ assertEquals("hello back", decrypted.value)
+ }
+
+ @Test
+ fun nip44IsRefusedWhenTheHostDidNotDeclareTheSignerCapability() =
+ runTest {
+ val prompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS)
+ val broker = broker(prompt)
+
+ // A locked napplet's declared set can never contain SIGNER (no NAP domain maps to it),
+ // so the request must die at the capability gate without ever reaching a prompt.
+ val response =
+ broker.handle(
+ applet,
+ NappletRequest.Nip44Decrypt("bb".repeat(32), "cipher"),
+ setOf(NappletCapability.IDENTITY, NappletCapability.RELAY),
+ )
+
+ assertIs(response)
+ assertEquals(NappletCapability.SIGNER, response.capability)
+ assertEquals(0, prompt.calls)
+ }
+
+ @Test
+ fun noNapDomainCanEverGrantTheSignerCapability() {
+ // The website-only guarantee is structural, not a policy someone can misconfigure: if any
+ // domain string ever mapped to SIGNER, a manifest could ask for the user's decryption.
+ assertNull(NappletCapability.fromNapDomain("signer"))
+ assertTrue(NappletCapability.supportedNapDomains.none { NappletCapability.fromNapDomain(it) == NappletCapability.SIGNER })
+ }
+
+ @Test
+ fun decryptAsksEveryTimeUnderTheReasonablePolicyWhileEncryptDoesNot() =
+ runTest {
+ // The asymmetry is the point: encrypting writes content the user is already composing,
+ // decrypting reads private content, so only the latter keeps prompting.
+ val signerLedger = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore())
+ signerLedger.setPolicy("napplet:${signer.pubKey}:${applet.coordinate}", AppSignerPolicy.REASONABLE)
+
+ val opPrompt = ScriptedSignerPrompt(SignerOpGrant.AllowOnce)
+ val broker =
+ NappletBroker(
+ signer = signer,
+ ledger = NappletPermissionLedger(InMemoryNappletPermissionStore()),
+ consentPrompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS),
+ signerLedger = signerLedger,
+ signerConsentPrompt = opPrompt,
+ )
+
+ val peer = NostrSignerInternal(KeyPair("22".repeat(32).hexToByteArray()))
+ broker.handle(applet, NappletRequest.Nip44Encrypt(peer.pubKey, "a"), allDeclared)
+ broker.handle(applet, NappletRequest.Nip44Encrypt(peer.pubKey, "b"), allDeclared)
+ assertEquals(0, opPrompt.calls)
+
+ val sealed = peer.nip44Encrypt("secret", signer.pubKey)
+ broker.handle(applet, NappletRequest.Nip44Decrypt(peer.pubKey, sealed), allDeclared)
+ broker.handle(applet, NappletRequest.Nip44Decrypt(peer.pubKey, sealed), allDeclared)
+ assertEquals(2, opPrompt.calls)
+ }
+
+ @Test
+ fun aDeclinedDecryptReturnsNoPlaintext() =
+ runTest {
+ val signerLedger = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore())
+ signerLedger.setPolicy("napplet:${signer.pubKey}:${applet.coordinate}", AppSignerPolicy.PARANOID)
+
+ val broker =
+ NappletBroker(
+ signer = signer,
+ ledger = NappletPermissionLedger(InMemoryNappletPermissionStore()),
+ consentPrompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS),
+ signerLedger = signerLedger,
+ signerConsentPrompt = ScriptedSignerPrompt(SignerOpGrant.DenyOnce),
+ )
+
+ val peer = NostrSignerInternal(KeyPair("33".repeat(32).hexToByteArray()))
+ val sealed = peer.nip44Encrypt("secret", signer.pubKey)
+
+ val response = broker.handle(applet, NappletRequest.Nip44Decrypt(peer.pubKey, sealed), allDeclared)
+ assertIs(response)
+ }
+
+ @Test
+ fun theWebsiteCapabilitySetCarriesEverythingNip07Needs() {
+ // Two places mint this set (the nSite HostProfile and the in-app browser's per-origin
+ // token). They drifted once: the browser kept IDENTITY+RELAY while the shim advertised
+ // nip44, so every call was denied by a capability the page was told it had.
+ assertTrue(NappletCapability.IDENTITY in NappletCapability.WEBSITE_CAPABILITIES)
+ assertTrue(NappletCapability.RELAY in NappletCapability.WEBSITE_CAPABILITIES)
+ assertTrue(NappletCapability.SIGNER in NappletCapability.WEBSITE_CAPABILITIES)
+ }
+
+ @Test
+ fun nip44WorksUnderTheWebsiteCapabilitySetAlone() =
+ runTest {
+ // What a browsed page actually gets — not `allDeclared`, which would hide a missing grant.
+ val peer = NostrSignerInternal(KeyPair("44".repeat(32).hexToByteArray()))
+ val broker = broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS))
+
+ val response =
+ broker.handle(
+ applet,
+ NappletRequest.Nip44Encrypt(peer.pubKey, "gm"),
+ NappletCapability.WEBSITE_CAPABILITIES,
+ )
+
+ assertIs(response)
+ assertEquals("gm", peer.nip44Decrypt(response.value, signer.pubKey))
+ }
+
+ @Test
+ fun anAlwaysAllowForOneCounterpartyDoesNotUnlockTheRest() =
+ runTest {
+ // The dialog can hand back DecryptFrom(alice) instead of the broad Decrypt. Recording the
+ // REQUESTED op there would silently upgrade "only Alice" into every conversation forever.
+ val signerLedger = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore())
+ signerLedger.setPolicy("napplet:${signer.pubKey}:${applet.coordinate}", AppSignerPolicy.REASONABLE)
+
+ val alice = NostrSignerInternal(KeyPair("55".repeat(32).hexToByteArray()))
+ val bob = NostrSignerInternal(KeyPair("66".repeat(32).hexToByteArray()))
+
+ val opPrompt = ScriptedSignerPrompt(SignerOpGrant.AllowForOp(NostrSignerOp.DecryptFrom(alice.pubKey)))
+ val broker =
+ NappletBroker(
+ signer = signer,
+ ledger = NappletPermissionLedger(InMemoryNappletPermissionStore()),
+ consentPrompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS),
+ signerLedger = signerLedger,
+ signerConsentPrompt = opPrompt,
+ )
+
+ val fromAlice = alice.nip44Encrypt("hi", signer.pubKey)
+ val fromBob = bob.nip44Encrypt("hi", signer.pubKey)
+
+ // 1. First read from Alice prompts; the user allows, but only for Alice.
+ assertIs(broker.handle(applet, NappletRequest.Nip44Decrypt(alice.pubKey, fromAlice), allDeclared))
+ assertEquals(1, opPrompt.calls)
+
+ // 2. Reading Alice again rides the narrow grant — no second prompt.
+ assertIs(broker.handle(applet, NappletRequest.Nip44Decrypt(alice.pubKey, fromAlice), allDeclared))
+ assertEquals(1, opPrompt.calls)
+
+ // 3. Bob is a different conversation and must ask again. If the broad Decrypt had been
+ // recorded in step 1, this would sail through without the user ever agreeing to it.
+ broker.handle(applet, NappletRequest.Nip44Decrypt(bob.pubKey, fromBob), allDeclared)
+ assertEquals(2, opPrompt.calls)
+
+ // The broad grant was never written.
+ assertNull(signerLedger.store.loadOpDecision("napplet:${signer.pubKey}:${applet.coordinate}", NostrSignerOp.Decrypt))
+ }
+
+ @Test
+ fun aSessionGrantIsStoredNoWiderThanTheUserGaveIt() =
+ runTest {
+ val signerLedger = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore())
+ signerLedger.setPolicy("napplet:${signer.pubKey}:${applet.coordinate}", AppSignerPolicy.PARANOID)
+
+ val alice = NostrSignerInternal(KeyPair("77".repeat(32).hexToByteArray()))
+ val bob = NostrSignerInternal(KeyPair("88".repeat(32).hexToByteArray()))
+
+ val opPrompt = ScriptedSignerPrompt(SignerOpGrant.AllowForSession(NostrSignerOp.DecryptFrom(alice.pubKey)))
+ val broker =
+ NappletBroker(
+ signer = signer,
+ ledger = NappletPermissionLedger(InMemoryNappletPermissionStore()),
+ consentPrompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS),
+ signerLedger = signerLedger,
+ signerConsentPrompt = opPrompt,
+ )
+
+ broker.handle(applet, NappletRequest.Nip44Decrypt(alice.pubKey, alice.nip44Encrypt("a", signer.pubKey)), allDeclared)
+ assertEquals(1, opPrompt.calls)
+
+ // Same counterparty rides the session grant; a different one must not.
+ broker.handle(applet, NappletRequest.Nip44Decrypt(alice.pubKey, alice.nip44Encrypt("a", signer.pubKey)), allDeclared)
+ assertEquals(1, opPrompt.calls)
+ broker.handle(applet, NappletRequest.Nip44Decrypt(bob.pubKey, bob.nip44Encrypt("b", signer.pubKey)), allDeclared)
+ assertEquals(2, opPrompt.calls)
+ }
}
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt
index 7cfb2a95e0..027cae99df 100644
--- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt
@@ -169,6 +169,10 @@ object NappletProtocolJson {
createdAt = t["created_at"]?.jsonPrimitive?.long ?: (System.currentTimeMillis() / 1000),
)
}
+ // NIP-07 nip44.encrypt/decrypt: crypto only, no publish. `peer` is the counterparty
+ // pubkey the NIP-07 call names as its first argument.
+ "nostr.nip44Encrypt" -> NappletRequest.Nip44Encrypt(peer = o.req("peer"), plaintext = o.str("plaintext") ?: "")
+ "nostr.nip44Decrypt" -> NappletRequest.Nip44Decrypt(peer = o.req("peer"), ciphertext = o.str("ciphertext") ?: "")
"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())
@@ -253,6 +257,10 @@ object NappletProtocolJson {
put("ok", true)
put("value", response.value)
}
+ is NappletResponse.Text -> {
+ put("ok", true)
+ put("value", response.value)
+ }
is NappletResponse.Strings -> {
put("ok", true)
// storage.keys returns `keys`; other string-list reads use `values`.
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/HostProfile.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/HostProfile.kt
index eb9e32e67c..698362883e 100644
--- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/HostProfile.kt
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/HostProfile.kt
@@ -41,12 +41,17 @@ enum class HostProfile {
/**
* What this posture is allowed to ask the broker for — THE security decision, minted into the
- * launch token in the trusted main process. A website gets the IDENTITY + RELAY pair NIP-07 needs
- * (consent-gated); a locked napplet gets only what its manifest `requires` declares.
+ * launch token in the trusted main process. A website gets the IDENTITY + RELAY + SIGNER set
+ * NIP-07 needs (consent-gated); a locked napplet gets only what its manifest `requires` declares.
+ *
+ * SIGNER (NIP-44 encrypt/decrypt) is website-only by construction: no NIP-5D domain maps to it,
+ * so [resolveRequiredCapabilities] can never produce it for a napplet however its manifest is
+ * written. Without it a page can sign but not seal, which locks it out of NIP-17 and every other
+ * gift-wrapped protocol.
*/
fun declaredCapabilities(requires: List): Set =
when (this) {
- WEBSITE -> setOf(NappletCapability.IDENTITY, NappletCapability.RELAY)
+ WEBSITE -> NappletCapability.WEBSITE_CAPABILITIES
NAPPLET -> resolveRequiredCapabilities(requires).capabilities.toSet()
}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/tags/DayIndexTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/tags/DayIndexTag.kt
new file mode 100644
index 0000000000..a1181ee75b
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/tags/DayIndexTag.kt
@@ -0,0 +1,89 @@
+/*
+ * 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.quartz.nip52Calendar.appt.tags
+
+import com.vitorpamplona.quartz.nip01Core.core.has
+import com.vitorpamplona.quartz.utils.ensure
+
+/**
+ * NIP-52's uppercase `D` day index on a kind:31923 time-based calendar event: the
+ * day-granularity unix timestamp `floor(unix_seconds / 86400)`, one tag per UTC calendar
+ * day the event's `start`..`end` range spans.
+ *
+ * It is what makes an event discoverable by date — a client browsing "what's on the 14th"
+ * queries `{"kinds":[31923],"#D":[""]}` instead of pulling every calendar event in
+ * existence and filtering client-side. Uppercase single-letter tags are indexed by relays
+ * exactly like lowercase ones; the case only distinguishes it from the `d` identifier.
+ */
+class DayIndexTag {
+ companion object {
+ const val TAG_NAME = "D"
+
+ const val SECONDS_IN_DAY = 86400L
+
+ /**
+ * The most `D` tags one event may carry. NIP-52 puts no ceiling on the range, but a
+ * multi-year event would otherwise emit thousands of tags and blow past relay event-size
+ * limits; capping keeps a mistyped end date from producing an unpublishable event. Beyond
+ * the cap the range is truncated (the start day is always emitted), never dropped.
+ */
+ const val MAX_DAYS = 366
+
+ /** The day index containing [timestamp] (unix seconds). */
+ fun dayIndex(timestamp: Long): Long = timestamp.floorDiv(SECONDS_IN_DAY)
+
+ fun parse(tag: Array): Long? {
+ ensure(tag.has(1)) { return null }
+ ensure(tag[0] == TAG_NAME) { return null }
+ return tag[1].toLongOrNull()
+ }
+
+ fun assemble(dayIndex: Long) = arrayOf(TAG_NAME, dayIndex.toString())
+
+ /**
+ * Every day index a `start`..`end` range touches, in order.
+ *
+ * `end` is **exclusive** per NIP-52, so an event that finishes exactly at midnight does not
+ * claim the following day; an event with no end (or an end at/before start) is instantaneous
+ * and occupies only its start day. Result is capped at [MAX_DAYS].
+ */
+ fun dayIndexes(
+ start: Long,
+ end: Long? = null,
+ ): List {
+ val first = dayIndex(start)
+ if (end == null || end <= start) return listOf(first)
+
+ // An exclusive end lands on the first instant NOT in the range, so the last day is the
+ // one holding end-1. Without the -1, an event ending at 00:00:00 would tag the next day.
+ val last = dayIndex(end - 1)
+ if (last <= first) return listOf(first)
+
+ return (first..minOf(last, first + MAX_DAYS - 1)).toList()
+ }
+
+ /** [dayIndexes] already assembled into tags. */
+ fun assembleAll(
+ start: Long,
+ end: Long? = null,
+ ) = dayIndexes(start, end).map { assemble(it) }
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt
index af20d0abfa..a5888a45c2 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
+import com.vitorpamplona.quartz.nip52Calendar.appt.tags.DayIndexTag
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.LocationTag
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.uuid.ExperimentalUuidApi
@@ -76,6 +77,9 @@ class CalendarTimeSlotEvent(
fun endTzId() = tags.firstTagValue("end_tzid")
+ /** The NIP-52 `D` day indexes this event claims. Empty for an event published without them. */
+ fun dayIndexes() = tags.mapNotNull(DayIndexTag.Companion::parse)
+
fun summary() = tags.firstNotNullOfOrNull(SummaryTag.Companion::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag.Companion::parse)
@@ -109,6 +113,9 @@ class CalendarTimeSlotEvent(
end?.let { endTimestamp(it) }
startTzId?.let { startTzId(it) }
endTzId?.let { endTzId(it) }
+ // NIP-52 requires the `D` day index on 31923; without it the event is invisible to
+ // date-indexed calendar clients, which query by day rather than scanning every event.
+ dayIndexes(start, end)
initializer()
}
}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/TagArrayBuilderExt.kt
index 092eab71e0..9c8e4e06e1 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/TagArrayBuilderExt.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/TagArrayBuilderExt.kt
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
+import com.vitorpamplona.quartz.nip52Calendar.appt.tags.DayIndexTag
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.LocationTag
fun TagArrayBuilder.titleTime(title: String) = addUnique(TitleTag.assemble(title))
@@ -34,6 +35,16 @@ fun TagArrayBuilder.startTimestamp(timestamp: Long) = add
fun TagArrayBuilder.endTimestamp(timestamp: Long) = addUnique(arrayOf("end", timestamp.toString()))
+/**
+ * Replaces the `D` day-index set with the one [start]..[end] spans (NIP-52). Removing first is what
+ * makes an edit correct: shortening an event that used to run three days must drop the two days it
+ * no longer covers, and appending alone would leave them claiming it forever.
+ */
+fun TagArrayBuilder.dayIndexes(
+ start: Long,
+ end: Long? = null,
+) = remove(DayIndexTag.TAG_NAME).addAll(DayIndexTag.assembleAll(start, end))
+
fun TagArrayBuilder.startTzId(tzId: String) = addUnique(arrayOf("start_tzid", tzId))
fun TagArrayBuilder.endTzId(tzId: String) = addUnique(arrayOf("end_tzid", tzId))
diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/DayIndexTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/DayIndexTagTest.kt
new file mode 100644
index 0000000000..3805ebd246
--- /dev/null
+++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/DayIndexTagTest.kt
@@ -0,0 +1,133 @@
+/*
+ * 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.quartz.nip52Calendar
+
+import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
+import com.vitorpamplona.quartz.nip52Calendar.appt.tags.DayIndexTag
+import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
+import com.vitorpamplona.quartz.nip52Calendar.appt.time.dayIndexes
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * NIP-52's uppercase `D` day index on kind:31923 — the tag that makes a calendar event
+ * discoverable by date. Covers the exclusive-`end` boundary and the case split from the
+ * lowercase `d` identifier, which shares the same tag map.
+ */
+class DayIndexTagTest {
+ // 2026-02-14T00:00:00Z — day index 20498.
+ private val feb14 = 1771027200L
+ private val dayIndexFeb14 = 20498L
+
+ @Test
+ fun dayIndexMatchesTheSpecFormula() {
+ // The worked example from NIP-52 itself.
+ assertEquals(82549L, DayIndexTag.dayIndex(82549L * 86400L))
+ assertEquals(dayIndexFeb14, DayIndexTag.dayIndex(feb14))
+ // Any instant inside the day maps to the same index.
+ assertEquals(dayIndexFeb14, DayIndexTag.dayIndex(feb14 + 86399))
+ }
+
+ @Test
+ fun noEndMeansASingleDay() {
+ assertEquals(listOf(dayIndexFeb14), DayIndexTag.dayIndexes(feb14 + 3600))
+ }
+
+ @Test
+ fun endBeforeOrAtStartStillEmitsTheStartDay() {
+ assertEquals(listOf(dayIndexFeb14), DayIndexTag.dayIndexes(feb14, feb14))
+ assertEquals(listOf(dayIndexFeb14), DayIndexTag.dayIndexes(feb14 + 100, feb14))
+ }
+
+ @Test
+ fun aMultiDayRangeCoversEveryDayItTouches() {
+ // 14th 10:00 → 16th 15:00 spans three days.
+ val days = DayIndexTag.dayIndexes(feb14 + 10 * 3600, feb14 + 2 * 86400 + 15 * 3600)
+ assertEquals(listOf(dayIndexFeb14, dayIndexFeb14 + 1, dayIndexFeb14 + 2), days)
+ }
+
+ @Test
+ fun endIsExclusiveSoMidnightDoesNotClaimTheNextDay() {
+ // Ends exactly at the 15th 00:00:00 — that instant belongs to the 15th, but the event
+ // does not, so only the 14th is tagged.
+ assertEquals(listOf(dayIndexFeb14), DayIndexTag.dayIndexes(feb14, feb14 + 86400))
+ // One second later it genuinely runs into the 15th.
+ assertEquals(
+ listOf(dayIndexFeb14, dayIndexFeb14 + 1),
+ DayIndexTag.dayIndexes(feb14, feb14 + 86401),
+ )
+ }
+
+ @Test
+ fun anAbsurdRangeIsCappedRatherThanEmittingThousandsOfTags() {
+ val days = DayIndexTag.dayIndexes(feb14, feb14 + 4000L * 86400L)
+ assertEquals(DayIndexTag.MAX_DAYS, days.size)
+ assertEquals(dayIndexFeb14, days.first())
+ }
+
+ @Test
+ fun buildEmitsTheDayTagsAndKeepsTheIdentifierSeparate() {
+ val template =
+ CalendarTimeSlotEvent.build(
+ title = "Nostrautica",
+ start = feb14 + 10 * 3600,
+ end = feb14 + 86400 + 2 * 3600,
+ dTag = "my-event",
+ )
+
+ val dayTags = template.tags.filter { it[0] == DayIndexTag.TAG_NAME }.map { it[1] }
+ assertEquals(listOf(dayIndexFeb14.toString(), (dayIndexFeb14 + 1).toString()), dayTags)
+
+ // The uppercase D must not disturb the lowercase d identifier — they collide in the
+ // builder's tag map if either side is ever case-folded.
+ assertEquals("my-event", template.tags.firstTagValue("d"))
+ }
+
+ @Test
+ fun parseReadsBackWhatBuildWrote() {
+ val template = CalendarTimeSlotEvent.build(title = "T", start = feb14, end = feb14 + 86401)
+ val event = CalendarTimeSlotEvent("id", "pub", 0L, template.tags, template.content, "sig")
+
+ assertEquals(listOf(dayIndexFeb14, dayIndexFeb14 + 1), event.dayIndexes())
+ }
+
+ @Test
+ fun rebuildingAfterAnEditDropsDaysTheEventNoLongerCovers() {
+ // A three-day event shortened to one: the two dropped days must not linger, or they keep
+ // advertising the event on dates it no longer runs.
+ val shortened =
+ CalendarTimeSlotEvent.build(title = "T", start = feb14, end = feb14 + 3 * 86400, dTag = "same") {
+ dayIndexes(feb14, feb14 + 3600)
+ }
+
+ val dayTags = shortened.tags.filter { it[0] == DayIndexTag.TAG_NAME }.map { it[1] }
+ assertEquals(listOf(dayIndexFeb14.toString()), dayTags)
+ }
+
+ @Test
+ fun parseRejectsNonDayTagsAndGarbage() {
+ assertEquals(null, DayIndexTag.parse(arrayOf("d", "1234")))
+ assertEquals(null, DayIndexTag.parse(arrayOf("D")))
+ assertEquals(null, DayIndexTag.parse(arrayOf("D", "not-a-number")))
+ assertTrue(DayIndexTag.parse(arrayOf("D", "82549")) == 82549L)
+ }
+}