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:
Vitor Pamplona
2026-09-10 21:28:07 -04:00
committed by GitHub
20 changed files with 699 additions and 37 deletions
@@ -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))
},
)
}
@@ -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
+4
View File
@@ -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 &amp; 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'); }
})
});
}
})();
@@ -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
@@ -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
@@ -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>,
@@ -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
}
@@ -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>,
@@ -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)
}
}
@@ -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()
}
@@ -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) }
}
}
@@ -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()
}
}
@@ -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))
@@ -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)
}
}