mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-09-14 00:55:08 +00:00
Merge pull request #4074 from vitorpamplona/claude/inspiring-euler-y6wfxc
Add NIP-44 encrypt/decrypt to NIP-07 window.nostr and NIP-52 day indexing
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
@@ -401,6 +401,7 @@
|
||||
<string name="napplet_cap_identity">Identity</string>
|
||||
<string name="napplet_cap_keys">Keyboard actions</string>
|
||||
<string name="napplet_cap_relay">Relays</string>
|
||||
<string name="napplet_cap_signer">Encryption</string>
|
||||
<string name="napplet_cap_storage">Storage</string>
|
||||
<string name="napplet_cap_value">Payments</string>
|
||||
<string name="napplet_cap_resource">Network</string>
|
||||
@@ -409,6 +410,7 @@
|
||||
<string name="napplet_cap_identity_desc">Read your public key</string>
|
||||
<string name="napplet_cap_keys_desc">Bind keyboard shortcuts</string>
|
||||
<string name="napplet_cap_relay_desc">Read, and sign & publish your events</string>
|
||||
<string name="napplet_cap_signer_desc">Encrypt and decrypt private messages with your key</string>
|
||||
<string name="napplet_cap_storage_desc">Its own private storage</string>
|
||||
<string name="napplet_cap_value_desc">Pay Lightning invoices</string>
|
||||
<string name="napplet_cap_resource_desc">Fetch web and Blossom resources</string>
|
||||
@@ -426,6 +428,8 @@
|
||||
<string name="napplet_consent_publish">This nApplet wants to sign and publish a kind %1$d event as you.</string>
|
||||
<string name="napplet_consent_publish_preview">This nApplet wants to sign and publish a kind %1$d event as you:</string>
|
||||
<string name="napplet_consent_publish_encrypted">This nApplet wants to send an encrypted event as you.</string>
|
||||
<string name="napplet_consent_nip44_encrypt">This site wants to encrypt a message to %1$s with your Nostr key.</string>
|
||||
<string name="napplet_consent_nip44_decrypt">This site wants to decrypt a message from %1$s with your Nostr key.</string>
|
||||
<string name="napplet_consent_query">This nApplet wants to read events from your relays.</string>
|
||||
<string name="napplet_consent_storage">This nApplet wants to use its private storage.</string>
|
||||
<string name="napplet_consent_pay">This nApplet wants to pay a Lightning invoice.</string>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'); }
|
||||
})
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
+45
-31
@@ -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
|
||||
|
||||
+27
@@ -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<NappletCapability> = 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
|
||||
|
||||
+35
@@ -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<Filter>,
|
||||
|
||||
+33
@@ -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
|
||||
}
|
||||
|
||||
+8
@@ -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<String>,
|
||||
|
||||
+203
@@ -745,4 +745,207 @@ class NappletBrokerTest {
|
||||
assertIs<NappletResponse.ResourceFailure>(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<NappletResponse.Text>(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<NappletResponse.Text>(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<NappletResponse.Denied>(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<NappletResponse.Denied>(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<NappletResponse.Text>(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<NappletResponse.Text>(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<NappletResponse.Text>(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)
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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`.
|
||||
|
||||
@@ -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<String>): Set<NappletCapability> =
|
||||
when (this) {
|
||||
WEBSITE -> setOf(NappletCapability.IDENTITY, NappletCapability.RELAY)
|
||||
WEBSITE -> NappletCapability.WEBSITE_CAPABILITIES
|
||||
NAPPLET -> resolveRequiredCapabilities(requires).capabilities.toSet()
|
||||
}
|
||||
|
||||
|
||||
+89
@@ -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":["<index>"]}` 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<String>): 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<Long> {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
+7
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -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<CalendarTimeSlotEvent>.titleTime(title: String) = addUnique(TitleTag.assemble(title))
|
||||
@@ -34,6 +35,16 @@ fun TagArrayBuilder<CalendarTimeSlotEvent>.startTimestamp(timestamp: Long) = add
|
||||
|
||||
fun TagArrayBuilder<CalendarTimeSlotEvent>.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<CalendarTimeSlotEvent>.dayIndexes(
|
||||
start: Long,
|
||||
end: Long? = null,
|
||||
) = remove(DayIndexTag.TAG_NAME).addAll(DayIndexTag.assembleAll(start, end))
|
||||
|
||||
fun TagArrayBuilder<CalendarTimeSlotEvent>.startTzId(tzId: String) = addUnique(arrayOf("start_tzid", tzId))
|
||||
|
||||
fun TagArrayBuilder<CalendarTimeSlotEvent>.endTzId(tzId: String) = addUnique(arrayOf("end_tzid", tzId))
|
||||
|
||||
+133
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user