feat(quartz): implement NIP-5D (Nostr Web Applets / napplets)

Adds the full NIP-5D napplet manifest layer, plus the NIP-5A aggregate-hash
infrastructure it depends on. Follows the nip88Polls package structure (event
class + tags/ + TagArrayExt + TagArrayBuilderExt).

NIP-5A shared infra (nip5aStaticWebsites):
- XTag — the aggregate-hash tag ["x", "<sha256>", "aggregate"].
- SiteAggregateHash — computes/verifies the NIP-5A aggregate hash: sort the
  per-path lines "<hash> <path>\n" lexicographically, concat as UTF-8, SHA-256.
  Pinned by a test against an independently computed sha256sum vector.
- siteAggregateHash() parse + builder extensions.

NIP-5D napplets (nip5dNapplets):
- NappletSnapshotEvent (5129, regular), RootNappletEvent (15129, replaceable),
  NamedNappletEvent (35129, addressable, d-tag) — all built on the NIP-5A
  path/server/title/description/source/x tag set.
- RequiresTag — ["requires", "<bare-nap-name>"] capability declarations.
- NappletManifest interface — uniform accessors (paths/servers/requires/title/
  …) plus computeAggregateHash()/verifyAggregate() shared across the three kinds.
  build() auto-stamps the x aggregate (required for snapshots, recommended for
  root/named).
- Registered all three kinds in EventFactory.

This covers the NIP-5D runtime verification contract end-to-end in quartz:
signature (core Event.verify), per-blob sha256 (StaticSiteResolver.verify), and
the aggregate x-tag (NappletManifest.verifyAggregate). Note the napplet kinds
5129/15129/35129 are distinct from the NIP-5A nsite kinds 5128/15128/35128, so
there is no collision.

Tests: SiteAggregateHashTest (vector + order-independence + tamper) and
NappletEventTest (build/parse round-trip for all three kinds, aggregate
verification, tamper detection, EventFactory routing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
This commit is contained in:
Claude
2026-06-19 22:06:49 +00:00
parent 1b2311e75f
commit 2648771429
14 changed files with 764 additions and 0 deletions
@@ -0,0 +1,66 @@
/*
* 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.nip5aStaticWebsites
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.utils.sha256.sha256
/**
* NIP-5A aggregate hash — a single content hash binding every file in a
* static-website / napplet manifest, carried in the `["x", <hash>, "aggregate"]`
* tag and verified by NIP-5D runtimes before executing a napplet.
*
* Algorithm (verbatim from NIP-5A):
* 1. Collect every `path` tag.
* 2. For each, produce a line `"<sha256hash> <absolute-path>\n"`.
* 3. Sort all lines in ascending lexicographic order.
* 4. Concatenate the sorted lines as UTF-8 bytes.
* 5. SHA-256 the concatenation.
*
* Lines are sorted by Kotlin's natural `String` order (UTF-16 code units), which
* matches a JavaScript `Array.prototype.sort()` reference implementation. In
* practice each line is prefixed by its unique 64-char hex hash, so ordering is
* decided by the hash and the path encoding only ever breaks ties between two
* paths sharing one blob.
*/
object SiteAggregateHash {
/** Recomputes the aggregate hash hex from a manifest's [paths]. */
fun compute(paths: List<PathTag>): HexKey {
val body =
paths
.map { "${it.hash} ${it.path}\n" }
.sorted()
.joinToString("")
return sha256(body.encodeToByteArray()).toHexKey()
}
/**
* Verifies a declared aggregate hash against the one recomputed from [paths].
* Returns `true` when [declared] is null (nothing to check) — per NIP-5D,
* the `x` tag is only enforced when present.
*/
fun verify(
paths: List<PathTag>,
declared: HexKey?,
): Boolean = declared == null || declared.equals(compute(paths), ignoreCase = true)
}
@@ -21,12 +21,14 @@
package com.vitorpamplona.quartz.nip5aStaticWebsites
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.DescriptionTag
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.ServerTag
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.SourceTag
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.TitleTag
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.XTag
fun <T : Event> TagArrayBuilder<T>.sitePaths(paths: List<PathTag>) = addAll(PathTag.assemble(paths))
@@ -37,3 +39,8 @@ fun <T : Event> TagArrayBuilder<T>.siteTitle(title: String) = addUnique(TitleTag
fun <T : Event> TagArrayBuilder<T>.siteDescription(description: String) = addUnique(DescriptionTag.assemble(description))
fun <T : Event> TagArrayBuilder<T>.siteSource(url: String) = addUnique(SourceTag.assemble(url))
fun <T : Event> TagArrayBuilder<T>.siteAggregateHash(aggregateHash: HexKey) = addUnique(XTag.assemble(aggregateHash))
/** Computes the NIP-5A aggregate hash from [paths] and adds it as the `x` tag. */
fun <T : Event> TagArrayBuilder<T>.siteAggregateHash(paths: List<PathTag>) = siteAggregateHash(SiteAggregateHash.compute(paths))
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.ServerTag
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.SourceTag
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.TitleTag
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.XTag
fun TagArray.sitePaths() = mapNotNull(PathTag::parse)
@@ -36,3 +37,5 @@ fun TagArray.siteTitle() = firstNotNullOfOrNull(TitleTag::parse)
fun TagArray.siteDescription() = firstNotNullOfOrNull(DescriptionTag::parse)
fun TagArray.siteSource() = firstNotNullOfOrNull(SourceTag::parse)
fun TagArray.siteAggregateHash() = firstNotNullOfOrNull(XTag::parse)
@@ -0,0 +1,51 @@
/*
* 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.nip5aStaticWebsites.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The NIP-5A aggregate-hash tag: `["x", "<sha256-hex>", "aggregate"]`.
*
* It pins the whole site/napplet manifest to a single content hash computed over
* all `path` tags (see [com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash]).
* The `"aggregate"` marker in position 2 distinguishes it from other `x` tags
* (e.g. NIP-94's bare `["x", "<hash>"]`).
*/
class XTag {
companion object {
const val TAG_NAME = "x"
const val AGGREGATE_MARKER = "aggregate"
/** Returns the aggregate hash hex when [tag] is a well-formed aggregate `x` tag, else null. */
fun parse(tag: Array<String>): HexKey? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
ensure(tag[2] == AGGREGATE_MARKER) { return null }
return tag[1]
}
fun assemble(aggregateHash: HexKey) = arrayOf(TAG_NAME, aggregateHash, AGGREGATE_MARKER)
}
}
@@ -0,0 +1,87 @@
/*
* 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.nip5dNapplets
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteAggregateHash
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription
import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteTitle
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-5D **named napplet** (kind 35129) — an addressable manifest identified by a
* `d` tag, so one pubkey can publish many named napplets. Carries the NIP-5A tag
* set; the `x` aggregate hash is recommended and included by [build].
*/
@Immutable
class NamedNappletEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
NappletManifest,
SearchableEvent {
override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n")
fun identifier() = dTag()
companion object {
const val KIND = 35129
const val ALT_DESCRIPTION = "Named Napplet"
fun build(
identifier: String,
paths: List<PathTag>,
servers: List<String> = emptyList(),
requires: List<String> = emptyList(),
title: String? = null,
description: String? = null,
source: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<NamedNappletEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
dTag(identifier)
sitePaths(paths)
siteAggregateHash(paths)
if (servers.isNotEmpty()) siteServers(servers)
if (requires.isNotEmpty()) nappletRequires(requires)
title?.let { siteTitle(it) }
description?.let { siteDescription(it) }
source?.let { siteSource(it) }
initializer()
}
}
}
@@ -0,0 +1,72 @@
/*
* 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.nip5dNapplets
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteAggregateHash
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription
import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteTitle
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
/**
* Common surface for the three NIP-5D napplet manifest kinds — snapshot
* ([NappletSnapshotEvent] / 5129), root ([RootNappletEvent] / 15129), and named
* ([NamedNappletEvent] / 35129). They carry the same NIP-5A tag set and differ
* only in replaceability and the `d` identifier, so all accessors live here and
* read off the event's [tags].
*/
interface NappletManifest {
val tags: TagArray
/** `path` tags mapping each absolute request path to its blob's sha256. */
fun paths(): List<PathTag> = tags.sitePaths()
/** `server` tags hinting which Blossom servers hold the blobs. */
fun servers(): List<String> = tags.siteServers()
/** `requires` tags: bare NAP capability domains the napplet needs from the shell. */
fun requires(): List<String> = tags.nappletRequires()
/** The aggregate hash declared in the `x` tag, or null when absent. */
fun declaredAggregateHash(): HexKey? = tags.siteAggregateHash()
fun title(): String? = tags.siteTitle()
fun description(): String? = tags.siteDescription()
fun source(): String? = tags.siteSource()
/** The NIP-5A aggregate hash recomputed from this manifest's [paths]. */
fun computeAggregateHash(): HexKey = SiteAggregateHash.compute(paths())
/**
* Verifies the declared `x` aggregate hash against the one recomputed from the
* `path` tags. Returns `true` when no `x` tag is present — per NIP-5D it is only
* enforced when carried. A runtime MUST still verify each blob's own sha256
* separately (see `StaticSiteResolver`).
*/
fun verifyAggregate(): Boolean = SiteAggregateHash.verify(paths(), declaredAggregateHash())
}
@@ -0,0 +1,82 @@
/*
* 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.nip5dNapplets
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteAggregateHash
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription
import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteTitle
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-5D napplet **snapshot** (kind 5129) — a regular, immutable event that pins
* one exact napplet build. Unlike the replaceable root/named manifests, a snapshot
* is permanent version history; it MUST carry the `x` aggregate hash.
*/
@Immutable
class NappletSnapshotEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
NappletManifest,
SearchableEvent {
override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n")
companion object {
const val KIND = 5129
const val ALT_DESCRIPTION = "Napplet snapshot"
fun build(
paths: List<PathTag>,
servers: List<String> = emptyList(),
requires: List<String> = emptyList(),
title: String? = null,
description: String? = null,
source: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<NappletSnapshotEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
sitePaths(paths)
siteAggregateHash(paths)
if (servers.isNotEmpty()) siteServers(servers)
if (requires.isNotEmpty()) nappletRequires(requires)
title?.let { siteTitle(it) }
description?.let { siteDescription(it) }
source?.let { siteSource(it) }
initializer()
}
}
}
@@ -0,0 +1,82 @@
/*
* 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.nip5dNapplets
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteAggregateHash
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription
import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteTitle
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-5D **root napplet** (kind 15129) — the replaceable manifest for a pubkey's
* default napplet. One per author; newer events replace older ones. Carries the
* NIP-5A tag set; the `x` aggregate hash is recommended and included by [build].
*/
@Immutable
class RootNappletEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
NappletManifest,
SearchableEvent {
override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n")
companion object {
const val KIND = 15129
const val ALT_DESCRIPTION = "Napplet"
fun build(
paths: List<PathTag>,
servers: List<String> = emptyList(),
requires: List<String> = emptyList(),
title: String? = null,
description: String? = null,
source: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RootNappletEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
sitePaths(paths)
siteAggregateHash(paths)
if (servers.isNotEmpty()) siteServers(servers)
if (requires.isNotEmpty()) nappletRequires(requires)
title?.let { siteTitle(it) }
description?.let { siteDescription(it) }
source?.let { siteSource(it) }
initializer()
}
}
}
@@ -0,0 +1,27 @@
/*
* 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.nip5dNapplets
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip5dNapplets.tags.RequiresTag
fun <T : Event> TagArrayBuilder<T>.nappletRequires(napNames: List<String>) = addAll(RequiresTag.assemble(napNames))
@@ -0,0 +1,26 @@
/*
* 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.nip5dNapplets
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip5dNapplets.tags.RequiresTag
fun TagArray.nappletRequires() = mapNotNull(RequiresTag::parse)
@@ -0,0 +1,49 @@
/*
* 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.nip5dNapplets.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-5D capability-requirement tag: `["requires", "<nap-name>"]`.
*
* Each value is a bare NAP domain the napplet needs from its shell — e.g.
* `relay`, `identity`, `storage` — never the `NAP-RELAY` spec name. A shell uses
* these to decide which capabilities to broker (and which to deny) before running
* the napplet.
*/
class RequiresTag {
companion object {
const val TAG_NAME = "requires"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(napName: String) = arrayOf(TAG_NAME, napName)
fun assemble(napNames: List<String>) = napNames.map { assemble(it) }
}
}
@@ -197,6 +197,9 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
import com.vitorpamplona.quartz.nip5dNapplets.NappletSnapshotEvent
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
@@ -498,6 +501,9 @@ class EventFactory {
TokenListEvent.KIND -> TokenListEvent(id, pubKey, createdAt, tags, content, sig)
TokenRemovalEvent.KIND -> TokenRemovalEvent(id, pubKey, createdAt, tags, content, sig)
NamedSiteEvent.KIND -> NamedSiteEvent(id, pubKey, createdAt, tags, content, sig)
NappletSnapshotEvent.KIND -> NappletSnapshotEvent(id, pubKey, createdAt, tags, content, sig)
RootNappletEvent.KIND -> RootNappletEvent(id, pubKey, createdAt, tags, content, sig)
NamedNappletEvent.KIND -> NamedNappletEvent(id, pubKey, createdAt, tags, content, sig)
NNSEvent.KIND -> NNSEvent(id, pubKey, createdAt, tags, content, sig)
NipTextEvent.KIND -> NipTextEvent(id, pubKey, createdAt, tags, content, sig)
NutzapEvent.KIND -> NutzapEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,66 @@
/*
* 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.nip5aStaticWebsites
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SiteAggregateHashTest {
private val h1 = "11".repeat(32)
private val h2 = "22".repeat(32)
@Test
fun matchesIndependentlyComputedVector() {
// Pinned with `printf '<h1> /index.html\n<h2> /app.js\n' | sha256sum` (h1 < h2, so sorted order).
val paths = listOf(PathTag("/index.html", h1), PathTag("/app.js", h2))
assertEquals(
"2c1250d51fba528f4d8c3c98522ecd844f6dcd94bcf3ecc90f3219bbc4a23224",
SiteAggregateHash.compute(paths),
)
}
@Test
fun isIndependentOfInputOrder() {
val forward = SiteAggregateHash.compute(listOf(PathTag("/index.html", h1), PathTag("/app.js", h2)))
val reversed = SiteAggregateHash.compute(listOf(PathTag("/app.js", h2), PathTag("/index.html", h1)))
assertEquals(forward, reversed)
}
@Test
fun verifyAcceptsNullAndMatchAndRejectsTamper() {
val paths = listOf(PathTag("/index.html", h1), PathTag("/app.js", h2))
val aggregate = SiteAggregateHash.compute(paths)
// No x tag declared -> nothing to enforce.
assertTrue(SiteAggregateHash.verify(paths, null))
// Declared matches, case-insensitively.
assertTrue(SiteAggregateHash.verify(paths, aggregate))
assertTrue(SiteAggregateHash.verify(paths, aggregate.uppercase()))
// Declared aggregate that doesn't match the paths is rejected.
assertFalse(SiteAggregateHash.verify(paths, h1))
// Tampering a single path hash changes the recomputed aggregate -> mismatch.
val tampered = listOf(PathTag("/index.html", h2), PathTag("/app.js", h2))
assertFalse(SiteAggregateHash.verify(tampered, aggregate))
}
}
@@ -0,0 +1,140 @@
/*
* 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.nip5dNapplets
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class NappletEventTest {
private val zero = "00".repeat(32)
private val h1 = "11".repeat(32)
private val h2 = "22".repeat(32)
private val paths = listOf(PathTag("/index.html", h1), PathTag("/app.js", h2))
private val servers = listOf("https://cdn.example.com")
private val requires = listOf("relay", "identity")
private fun <T : Event> materialize(
template: EventTemplate<T>,
factory: (String, String, Long, Array<Array<String>>, String, String) -> T,
): T = factory(zero, zero, template.createdAt, template.tags, template.content, zero)
@Test
fun namedNappletRoundTrips() {
val event =
materialize(
NamedNappletEvent.build(
identifier = "calculator",
paths = paths,
servers = servers,
requires = requires,
title = "Calc",
description = "a calculator",
source = "https://github.com/x/calc",
),
::NamedNappletEvent,
)
assertEquals(35129, event.kind)
assertEquals("calculator", event.identifier())
assertEquals(paths.map { it.path }, event.paths().map { it.path })
assertEquals(paths.map { it.hash }, event.paths().map { it.hash })
assertEquals(servers, event.servers())
assertEquals(requires, event.requires())
assertEquals("Calc", event.title())
assertEquals("a calculator", event.description())
assertEquals("https://github.com/x/calc", event.source())
// build() stamps the x aggregate, and it verifies against the path tags.
assertNotNull(event.declaredAggregateHash())
assertEquals(event.computeAggregateHash(), event.declaredAggregateHash())
assertTrue(event.verifyAggregate())
}
@Test
fun rootNappletHasKind15129AndNoIdentifierNeeded() {
val event = materialize(RootNappletEvent.build(paths = paths), ::RootNappletEvent)
assertEquals(15129, event.kind)
assertTrue(event.verifyAggregate())
}
@Test
fun snapshotHasKind5129AndAlwaysCarriesAggregate() {
val event = materialize(NappletSnapshotEvent.build(paths = paths), ::NappletSnapshotEvent)
assertEquals(5129, event.kind)
assertNotNull(event.declaredAggregateHash())
assertTrue(event.verifyAggregate())
}
@Test
fun tamperedPathBreaksAggregateVerification() {
// Build a valid manifest, then rewrite one path hash in the tags without
// touching the x tag — the recomputed aggregate no longer matches.
val template = NamedNappletEvent.build(identifier = "x", paths = paths)
val tamperedTags =
template.tags
.map { tag ->
if (tag.getOrNull(0) == PathTag.TAG_NAME && tag.getOrNull(1) == "/app.js") {
arrayOf(PathTag.TAG_NAME, "/app.js", h1)
} else {
tag
}
}.toTypedArray()
val tampered = NamedNappletEvent(zero, zero, template.createdAt, tamperedTags, template.content, zero)
assertFalse(tampered.verifyAggregate())
}
@Test
fun eventFactoryRoutesAllThreeNappletKinds() {
val named = NamedNappletEvent.build(identifier = "x", paths = paths)
val root = RootNappletEvent.build(paths = paths)
val snapshot = NappletSnapshotEvent.build(paths = paths)
assertIs<NamedNappletEvent>(
EventFactory.create<Event>(zero, zero, named.createdAt, NamedNappletEvent.KIND, named.tags, named.content, zero),
)
assertIs<RootNappletEvent>(
EventFactory.create<Event>(zero, zero, root.createdAt, RootNappletEvent.KIND, root.tags, root.content, zero),
)
assertIs<NappletSnapshotEvent>(
EventFactory.create<Event>(zero, zero, snapshot.createdAt, NappletSnapshotEvent.KIND, snapshot.tags, snapshot.content, zero),
)
}
@Test
fun emptyOptionalsAreOmitted() {
val event = materialize(RootNappletEvent.build(paths = paths), ::RootNappletEvent)
assertTrue(event.servers().isEmpty())
assertTrue(event.requires().isEmpty())
assertEquals(null, event.title())
// Only path + x (+ alt) tags expected; the manifest still has its paths.
assertContentEquals(paths.map { it.hash }, event.paths().map { it.hash })
}
}