Merge pull request #3296 from vitorpamplona/claude/napplet-protocol-n2ihd6

Add NIP-5A static-site resolver + NIP-5D napplet support
This commit is contained in:
Vitor Pamplona
2026-06-19 18:26:36 -04:00
committed by GitHub
24 changed files with 1868 additions and 0 deletions
@@ -162,6 +162,14 @@ private suspend fun dispatch(argv: Array<String>): Int {
Commands.notes(dataDir, tail)
}
"nsite" -> {
Commands.nsite(dataDir, tail)
}
"napplet" -> {
Commands.napplet(dataDir, tail)
}
"store" -> {
Commands.store(dataDir, tail)
}
@@ -358,6 +366,21 @@ private fun printUsage() {
| [--since TS] [--until TS]
| [--timeout SECS]
|
|Static websites (NIP-5A kind:15128/35128):
| nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and
| [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin
| [--out FILE] [--timeout SECS] (AUTHOR: npub|nprofile|hex|name@domain;
| [--max-inline-bytes N] --d selects a kind:35128 named site, else the
| kind:15128 root site; --path defaults to /)
|
|Napplets (NIP-5D kind:5129/15129/35129):
| napplet fetch AUTHOR [--d ID] [--path P] like `nsite fetch`, plus NIP-5D verification:
| [--server URL[,URL]] [--relay URL[,URL]] recompute + check the `x` aggregate hash and
| [--out FILE] [--timeout SECS] report the napplet's `requires` capabilities
| [--max-inline-bytes N] (--d selects a kind:35129 named napplet, else
| napplet fetch --snapshot EVENT-ID the kind:15129 root; --snapshot pins a kind:5129
| [--path P] … immutable snapshot by event id)
|
|Contacts (NIP-02 kind:3):
| follow USER [--timeout SECS] add USER to your contact list
| unfollow USER [--timeout SECS] remove USER from your contact list
@@ -91,6 +91,16 @@ object Commands {
tail: Array<String>,
): Int = NotesCommands.dispatch(dataDir, tail)
suspend fun nsite(
dataDir: DataDir,
tail: Array<String>,
): Int = NsiteCommands.dispatch(dataDir, tail)
suspend fun napplet(
dataDir: DataDir,
tail: Array<String>,
): Int = NappletCommands.dispatch(dataDir, tail)
suspend fun store(
dataDir: DataDir,
tail: Array<String>,
@@ -0,0 +1,216 @@
/*
* 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.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
import com.vitorpamplona.quartz.nip5dNapplets.NappletSnapshotEvent
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
/**
* `amy napplet fetch` — resolve a single path of a NIP-5D napplet over
* Nostr + Blossom with the full runtime verification contract:
*
* 1. fetch + signature-verify the manifest (kind 15129 root, 35129 named with
* `--d`, or 5129 snapshot with `--snapshot <event-id>`),
* 2. recompute the NIP-5A aggregate hash and check it against the manifest's `x`
* tag — a mismatch is refused up front (`aggregate_mismatch`),
* 3. download the path's blob from Blossom and accept only a copy whose sha256
* matches the per-path pin (an untrusted server cannot substitute a blob).
*
* It also reports the napplet's `requires` capabilities, so a shell can see which
* NAP domains it would have to broker. Steps 2–3 live in quartz
* (`NappletManifest.verifyAggregate`, `StaticSiteResolver`); this is thin glue,
* shared with `amy nsite` via [StaticSiteFetch].
*/
object NappletCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "napplet <fetch> …")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"fetch" -> fetch(dataDir, rest)
else -> Output.error("bad_args", "napplet ${tail[0]}")
}
}
private suspend fun fetch(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val snapshotId = args.flag("snapshot")
val author = args.positionalOrNull(0)
if (snapshotId == null && author == null) {
return Output.error("bad_args", "napplet fetch <author> [--d ID] | --snapshot <event-id> [--path P]")
}
val identifier = args.flag("d")
val requestPath = args.flag("path", "/")!!
val outFile = args.flag("out")
val timeoutSecs = args.longFlag("timeout", 8L)
val maxInlineBytes = args.longFlag("max-inline-bytes", 65_536L)
val extraServers = StaticSiteFetch.commaList(args.flag("server"))
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val relays =
extraRelays
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
.ifEmpty { ctx.bootstrapRelays() }
val event =
if (snapshotId != null) {
fetchSnapshot(ctx, snapshotId, relays, timeoutSecs * 1000)
} else {
val authorHex = ctx.requireUserHex(author!!)
fetchByAuthor(ctx, authorHex, identifier, relays, timeoutSecs * 1000)
}
if (event == null) {
return Output.error(
"not_found",
"no napplet manifest found",
mapOf(
"kind" to expectedKind(snapshotId, identifier),
"snapshot" to snapshotId,
"d" to identifier,
),
)
}
val manifest = event as NappletManifest
// NIP-5D step 3: the signed aggregate MUST match the path tags. Refuse a
// tampered/inconsistent manifest before fetching any third-party blob.
if (!manifest.verifyAggregate()) {
return Output.error(
"aggregate_mismatch",
"manifest x aggregate does not match its path tags",
mapOf(
"kind" to event.kind,
"manifest_event_id" to event.id,
"declared" to manifest.declaredAggregateHash(),
"computed" to manifest.computeAggregateHash(),
),
)
}
val declaredAggregate = manifest.declaredAggregateHash()
return StaticSiteFetch.resolveAndEmit(
requestPath = requestPath,
paths = manifest.paths(),
servers = (manifest.servers() + extraServers).distinct(),
manifestFields =
mapOf(
"kind" to event.kind,
"manifest_event_id" to event.id,
"d" to identifier,
"requires" to manifest.requires(),
"aggregate_sha256" to declaredAggregate,
"aggregate_verified" to (declaredAggregate != null),
),
outFile = outFile,
maxInlineBytes = maxInlineBytes,
)
} finally {
ctx.close()
}
}
private fun expectedKind(
snapshotId: String?,
identifier: String?,
): Int =
when {
snapshotId != null -> NappletSnapshotEvent.KIND
identifier != null -> NamedNappletEvent.KIND
else -> RootNappletEvent.KIND
}
/**
* Fetch the latest napplet manifest for [authorHex]: a [NamedNappletEvent] (kind
* 35129) addressed by [identifier] when `--d` is given, otherwise the author's
* root [RootNappletEvent] (kind 15129).
*/
private suspend fun fetchByAuthor(
ctx: Context,
authorHex: String,
identifier: String?,
relays: Set<NormalizedRelayUrl>,
timeoutMs: Long,
): Event? {
if (relays.isEmpty()) return null
val filter =
if (identifier != null) {
Filter(
kinds = listOf(NamedNappletEvent.KIND),
authors = listOf(authorHex),
tags = mapOf("d" to listOf(identifier)),
limit = 1,
)
} else {
Filter(kinds = listOf(RootNappletEvent.KIND), authors = listOf(authorHex), limit = 1)
}
return ctx
.drain(relays.associateWith { listOf(filter) }, timeoutMs)
.map { (_, ev) -> ev }
.filter { it.pubKey == authorHex && matchesIdentifier(it, identifier) }
.maxByOrNull { it.createdAt }
}
/** Fetch a specific immutable snapshot ([NappletSnapshotEvent] / kind 5129) by event id. */
private suspend fun fetchSnapshot(
ctx: Context,
eventId: String,
relays: Set<NormalizedRelayUrl>,
timeoutMs: Long,
): Event? {
if (relays.isEmpty()) return null
val filter = Filter(ids = listOf(eventId), kinds = listOf(NappletSnapshotEvent.KIND), limit = 1)
return ctx
.drain(relays.associateWith { listOf(filter) }, timeoutMs)
.map { (_, ev) -> ev }
.firstOrNull { it is NappletSnapshotEvent && it.id == eventId }
}
private fun matchesIdentifier(
event: Event,
identifier: String?,
): Boolean =
when (event) {
is NamedNappletEvent -> event.identifier() == identifier
is RootNappletEvent -> identifier == null
else -> false
}
}
@@ -0,0 +1,184 @@
/*
* 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.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
/**
* `amy nsite fetch` — resolve a single path of a NIP-5A static website over
* Nostr + Blossom, **verifying** the content against the signed manifest.
*
* The manifest (`RootSiteEvent` kind 15128, or `NamedSiteEvent` kind 35128 with
* `--d`) pins each path to a sha256. This command fetches the manifest from
* relays, then downloads the requested path's blob from the manifest's Blossom
* servers and accepts the first copy whose recomputed sha256 matches the pin — an
* untrusted server that substitutes or corrupts the blob is skipped.
*
* For NIP-5D napplets (kinds 5129/15129/35129, with aggregate-hash + capability
* verification) use `amy napplet fetch`.
*
* Thin-assembly only: resolution + verification live in quartz (`StaticSiteResolver`),
* the byte fetch in commons (`BlossomClient.download`), shared via [StaticSiteFetch].
*/
object NsiteCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "nsite <fetch> …")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"fetch" -> fetch(dataDir, rest)
else -> Output.error("bad_args", "nsite ${tail[0]}")
}
}
private suspend fun fetch(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val author = args.positionalOrNull(0) ?: return Output.error("bad_args", "nsite fetch <author> [--d ID] [--path P]")
val identifier = args.flag("d")
val requestPath = args.flag("path", "/")!!
val outFile = args.flag("out")
val timeoutSecs = args.longFlag("timeout", 8L)
val maxInlineBytes = args.longFlag("max-inline-bytes", 65_536L)
val extraServers = StaticSiteFetch.commaList(args.flag("server"))
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val authorHex = ctx.requireUserHex(author)
val relays =
extraRelays
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
.ifEmpty { ctx.bootstrapRelays() }
val manifest = fetchManifest(ctx, authorHex, identifier, relays, timeoutSecs * 1000)
if (manifest == null) {
return Output.error(
"not_found",
"no static-website manifest for this author",
mapOf(
"pubkey" to authorHex,
"kind" to if (identifier != null) NamedSiteEvent.KIND else RootSiteEvent.KIND,
"d" to identifier,
),
)
}
// Manifest servers first (author intent), then any --server fallbacks; keep order, dedupe.
return StaticSiteFetch.resolveAndEmit(
requestPath = requestPath,
paths = manifest.paths,
servers = (manifest.servers + extraServers).distinct(),
manifestFields =
mapOf(
"kind" to manifest.kind,
"manifest_event_id" to manifest.id,
"d" to identifier,
),
outFile = outFile,
maxInlineBytes = maxInlineBytes,
)
} finally {
ctx.close()
}
}
/**
* Fetch the latest matching manifest from [relays]: a [NamedSiteEvent] (kind
* 35128) addressed by [identifier] when `--d` is given, otherwise the author's
* root [RootSiteEvent] (kind 15128).
*/
private suspend fun fetchManifest(
ctx: Context,
authorHex: String,
identifier: String?,
relays: Set<NormalizedRelayUrl>,
timeoutMs: Long,
): SiteManifest? {
if (relays.isEmpty()) return null
val filter =
if (identifier != null) {
Filter(
kinds = listOf(NamedSiteEvent.KIND),
authors = listOf(authorHex),
tags = mapOf("d" to listOf(identifier)),
limit = 1,
)
} else {
Filter(kinds = listOf(RootSiteEvent.KIND), authors = listOf(authorHex), limit = 1)
}
val received = ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs)
return received
.map { (_, ev) -> ev }
.filter { it.pubKey == authorHex }
.mapNotNull { toManifest(it, identifier) }
.maxByOrNull { it.createdAt }
}
private fun toManifest(
event: Event,
identifier: String?,
): SiteManifest? =
when (event) {
is NamedSiteEvent ->
if (event.identifier() == identifier) {
SiteManifest(event.kind, event.id, event.createdAt, event.paths(), event.servers())
} else {
null
}
is RootSiteEvent ->
if (identifier == null) {
SiteManifest(event.kind, event.id, event.createdAt, event.paths(), event.servers())
} else {
null
}
else -> null
}
/**
* Flattened view of either manifest event type (`RootSiteEvent` /
* `NamedSiteEvent`) so the rest of the command doesn't branch on Root vs Named.
*/
private class SiteManifest(
val kind: Int,
val id: String,
val createdAt: Long,
val paths: List<PathTag>,
val servers: List<String>,
)
}
@@ -0,0 +1,131 @@
/*
* 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.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import java.io.File
/**
* Shared Blossom resolve-and-emit used by both `amy nsite` (NIP-5A) and
* `amy napplet` (NIP-5D): given a manifest's `path` + `server` tags, download the
* requested path's blob, verify its sha256 against the pin, and print the result.
* All resolution + verification lives in quartz (`StaticSiteResolver`); this is
* thin glue around it.
*/
internal object StaticSiteFetch {
fun commaList(value: String?): List<String> =
value
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?: emptyList()
/**
* Resolves [requestPath] against the manifest's [paths]/[servers], emitting the
* verified bytes (inlined for small text, or written to [outFile]) merged with
* the caller's [manifestFields] (kind, ids, requires, aggregate, …), or a
* structured `no_servers` / `path_not_found` / `unresolvable` error.
*/
suspend fun resolveAndEmit(
requestPath: String,
paths: List<PathTag>,
servers: List<String>,
manifestFields: Map<String, Any?>,
outFile: String?,
maxInlineBytes: Long,
): Int {
if (servers.isEmpty()) {
return Output.error("no_servers", "manifest lists no Blossom servers; pass --server URL")
}
val blossom = BlossomClient()
val resolution =
StaticSiteResolver.resolve(
requestPath = requestPath,
paths = paths,
servers = servers,
fetch = { url -> blossom.download(url) },
)
return when (resolution) {
is StaticSiteResolution.PathNotInManifest ->
Output.error(
"path_not_found",
"manifest declares no such path",
mapOf("path" to requestPath, "available_paths" to paths.map { it.path }),
)
is StaticSiteResolution.Unresolvable ->
Output.error(
"unresolvable",
"no server returned a blob matching the manifest hash",
mapOf("path" to requestPath, "sha256" to resolution.hash, "servers" to servers),
)
is StaticSiteResolution.Resolved -> {
emitResolved(resolution, requestPath, manifestFields, outFile, maxInlineBytes)
0
}
}
}
private fun emitResolved(
resolved: StaticSiteResolution.Resolved,
requestPath: String,
manifestFields: Map<String, Any?>,
outFile: String?,
maxInlineBytes: Long,
) {
val base =
linkedMapOf<String, Any?>(
"found" to true,
"verified" to true,
"request_path" to requestPath,
"manifest_path" to resolved.path,
"sha256" to resolved.hash,
"content_type" to resolved.contentType,
"size" to resolved.bytes.size,
"server" to resolved.server,
)
base.putAll(manifestFields)
if (outFile != null) {
File(outFile).writeBytes(resolved.bytes)
base["out"] = outFile
} else if (isTextual(resolved.contentType) && resolved.bytes.size <= maxInlineBytes) {
base["content"] = resolved.bytes.decodeToString()
} else {
base["note"] = "binary or large blob not inlined; pass --out FILE to save it"
}
Output.emit(base)
}
private fun isTextual(contentType: String): Boolean =
contentType.startsWith("text/") ||
contentType.startsWith("application/json") ||
contentType.startsWith("application/xml") ||
contentType.startsWith("image/svg")
}
@@ -81,6 +81,30 @@ open class BlossomClient(
}
}
/**
* Download a blob from an absolute URL — typically a Blossom GET endpoint
* `<server>/<sha256>`. Returns the raw bytes, or `null` when the server
* responds with a non-2xx status. Connection-level failures (DNS, refused,
* timeout) propagate as [java.io.IOException] so the caller can try the next
* server.
*
* This does NOT verify the blob's hash — content-addressed verification is
* the caller's responsibility (see quartz `StaticSiteResolver.verify`), since
* a Blossom server is untrusted and may return a substituted blob.
*/
open suspend fun download(url: String): ByteArray? =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(url)
.get()
.build()
okHttpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) response.body.bytes() else null
}
}
/**
* Upload raw bytes (e.g. encrypted blobs) to a Blossom server.
*/
@@ -0,0 +1,100 @@
# NIP-5A static-site resolver + napplet (NIP-5D) alignment
Date: 2026-06-19
Status: resolver landed (quartz commonMain); alignment questions open
## Context
[napplet.run](https://napplet.run) proposes "napplets" — small, sandboxed web
apps distributed over Nostr + Blossom, where a **shell** brokers dangerous
capabilities (signing, relay access, storage) and the applet runs as untrusted
code behind a trust boundary. Relevant upstream material:
- Web packages: <https://github.com/napplet/web>
- NAPs track (capability + wire-format specs): <https://github.com/napplet/naps>
- Runtime packages: <https://github.com/kehto/web>
- Playground: <https://kehto.github.io/web/playground>
The important overlap with Amethyst: napplet **distribution** reuses the nsite
static-website event shape that Quartz already implements —
`com.vitorpamplona.quartz.nip5aStaticWebsites` (`RootSiteEvent` kind 15128,
`NamedSiteEvent` kind 35128). Each manifest pins request paths to content-addressed
Blossom blobs via `path` tags (`[path, <sha256>]`) plus `server` tags. **NIP-5D**
is the web projection on top of NIP-5A (iframe hosting, `postMessage` transport,
`window.napplet.*` capability surface).
So Amethyst already owns the bottom half of the stack. The cheap, high-leverage
move (vs. building a full shell) is to be the reference **resolver** for NIP-5A and
help keep the event shape from forking across napplets / nsites / NMP / Tiles.
## What landed
A platform-agnostic resolver in `quartz/commonMain`, under
`nip5aStaticWebsites/resolver/`:
- **`StaticSitePathLookup.kt`** — `normalizeStaticPath()` (strips query/fragment +
leading slash, expands root/dir requests to `index.html`), `List<PathTag>.resolvePath()`
(leading-slash-insensitive match), `guessStaticContentType()` (web asset MIME map;
Blossom serves blobs untyped, so the host must label them).
- **`StaticSiteResolver.kt`** — `verify(blob, hash)`, `candidateUrls(servers, hash)`,
and `suspend resolve(requestPath, paths, servers, fetch): StaticSiteResolution`.
HTTP is injected via a `BlobFetcher` typealias so Quartz keeps no HTTP dependency;
`commons`/`amethyst` supply an OkHttp-backed fetcher.
**Trust model (the point):** the signed manifest is the authority; the Blossom
server is untrusted. `resolve` downloads the content-addressed blob from each listed
server in order and accepts the **first whose recomputed sha256 matches the pin**. A
server that substitutes/corrupts/truncates a blob fails verification and is silently
skipped — it can withhold content but can never forge it. Tests cover root/dir/query
normalization, slash-insensitive lookup, MIME guessing, and the security cases
(tampered server skipped → falls through to honest server; all-tampered →
`Unresolvable`; undeclared path → `PathNotInManifest` without fetching).
Deliberately **out of scope** in the protocol layer: SPA "serve index.html for any
unknown route" fallback (weakens the path→hash guarantee — a shell policy decision),
and the author's kind:10063 Blossom-list as a server fallback (caller appends it
before calling `resolve`; `BlossomServerResolver`/BUD-10 already exists in `amethyst`).
## Open alignment questions for the napplet author
These are worth resolving before three projects (napplets, nsites, NMP, Tiles) fork
the event shape. Raise upstream on napplet/naps:
1. **Manifest kind: 35128 vs 35129.** napplet/naps describes NIP-5A distribution as
**kind 35128** (the exact `NamedSiteEvent` Amethyst already renders), while
napplet/web describes the NIP-5D web manifest as **kind 35129**. Confirm the
intent: is a napplet a *plain* NIP-5A nsite (35128) that a NIP-5D-aware shell
simply *recognizes*, or a *distinct* 35129 event? If 35129 is distinct, what does
it add over 35128 — and should it embed/reference a 35128 rather than duplicate the
`path`/`server` tag set? Avoid silently colliding with the nsite 35128 Amethyst
already publishes and resolves.
2. **Capability declaration vs NIP-89.** napplet manifests carry a `requires` /
capability declaration (which NAP domains the applet needs: identity, relay,
value, …). Amethyst already models "an app handles these event kinds" via NIP-89
`AppDefinitionEvent` (kind 31990, `k`-tags). Should napplet capability `requires`
reuse NIP-89 semantics (or a documented superset) instead of a parallel tag, so a
single client can reason about both?
3. **Aggregate build hash.** Both repos mention an aggregate hash over the per-file
set. Is that pinned as a dedicated tag on the manifest (canonical
serialization/ordering defined), or only implied by the set of `path` hashes? The
resolver verifies per-file hashes today; if there is a canonical aggregate, Quartz
should expose `aggregateHash()` and verify it too.
4. **`server` semantics.** Are `server` tags an *ordered preference* list (our
resolver assumes order = priority) or an unordered set the host load-balances? And
is the author's kind:10063 Blossom list an implicit fallback, or must servers be
exhaustively listed on the manifest?
## Possible follow-ups (not in this change)
- Wire an OkHttp `BlobFetcher` in `commons` and point `StaticWebsite.kt` at the
resolver to render verified content (today it only shows manifest metadata + opens
links in an external browser).
- The full shell: Android WebView host (`allow-scripts`, no `allow-same-origin`) +
`postMessage`↔`NostrSigner`/Blossom/relay bridge + a per-applet permission ledger
(reuse the signer-prompt design in
`amethyst/plans/2026-05-25-appfunctions-signer-prompts.md`). The brokers it needs
(three `NostrSigner` types, Blossom upload/download, relay client, NIP-57 zaps) all
already ship — the WebView + consent UI are the only genuinely new pieces.
@@ -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,103 @@
/*
* 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.resolver
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
/*
* Request-path lookup for NIP-5A static-website / napplet manifests.
*
* A manifest (`RootSiteEvent` kind 15128 / `NamedSiteEvent` kind 35128) maps request
* paths to content-addressed blobs through `path` tags (`PathTag` = request path + the
* blob's lowercase sha256). The same event shape backs both nsites and napplets
* (NIP-5D web projection), so the lookup here is deliberately runtime-agnostic.
*
* These helpers apply the usual static-host path conventions before matching:
* - the query string (`?…`) and fragment (`#…`) are dropped,
* - a leading `/` (or `./`) is irrelevant to the match,
* - an empty path or a directory request (trailing `/`) resolves to `index.html`.
*
* The match itself stays strict and content-addressed — there is no SPA "serve
* index.html for any unknown route" fallback here. That is a host/shell policy
* decision (it weakens the path→hash guarantee) and belongs in the shell, not in
* the protocol layer.
*/
/** The implicit document served for the site root and for directory requests. */
const val STATIC_SITE_INDEX = "index.html"
/**
* Normalises a raw request path to the canonical form used for manifest matching:
* strips the query/fragment and any leading `/` or `./`, and expands a root or
* directory request to its `index.html` document.
*/
fun normalizeStaticPath(requestPath: String): String {
val withoutQuery = requestPath.substringBefore('?').substringBefore('#')
val trimmed = withoutQuery.removePrefix("./").removePrefix("/")
return when {
trimmed.isEmpty() -> STATIC_SITE_INDEX
trimmed.endsWith('/') -> trimmed + STATIC_SITE_INDEX
else -> trimmed
}
}
/** Canonical form of a manifest-declared path, so `/app.js` and `app.js` compare equal. */
private fun PathTag.canonicalPath() = path.removePrefix("./").removePrefix("/")
/**
* Finds the [PathTag] that serves [requestPath], applying [normalizeStaticPath] to the
* request and to each declared path before comparing. Returns `null` when the path is
* not declared in the manifest.
*/
fun List<PathTag>.resolvePath(requestPath: String): PathTag? {
val target = normalizeStaticPath(requestPath)
return firstOrNull { it.canonicalPath() == target }
}
/**
* Best-effort `Content-Type` for a manifest path, derived from its file extension.
* Blossom serves blobs untyped (content-addressed), so the host must label them; this
* covers the common web-runtime asset types and falls back to `application/octet-stream`.
*/
fun guessStaticContentType(path: String): String {
val ext = path.substringAfterLast('.', "").lowercase()
return when (ext) {
"html", "htm" -> "text/html; charset=utf-8"
"js", "mjs" -> "text/javascript; charset=utf-8"
"css" -> "text/css; charset=utf-8"
"json" -> "application/json; charset=utf-8"
"wasm" -> "application/wasm"
"svg" -> "image/svg+xml"
"png" -> "image/png"
"jpg", "jpeg" -> "image/jpeg"
"gif" -> "image/gif"
"webp" -> "image/webp"
"avif" -> "image/avif"
"ico" -> "image/x-icon"
"txt", "md" -> "text/plain; charset=utf-8"
"xml" -> "application/xml"
"woff2" -> "font/woff2"
"woff" -> "font/woff"
"ttf" -> "font/ttf"
"map" -> "application/json"
else -> "application/octet-stream"
}
}
@@ -0,0 +1,151 @@
/*
* 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.resolver
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
import kotlin.coroutines.cancellation.CancellationException
/**
* Fetches the raw bytes of a Blossom blob from an absolute URL, or returns `null` when
* the server is unreachable / responds with an error. Supplied by the host platform
* (e.g. an OkHttp-backed implementation in `commons`/`amethyst`) so that `quartz` carries
* no HTTP dependency and the resolver stays Kotlin-Multiplatform-pure.
*/
typealias BlobFetcher = suspend (url: String) -> ByteArray?
/** Outcome of resolving a single request path against a NIP-5A static-website manifest. */
sealed interface StaticSiteResolution {
/**
* The request path was declared in the manifest and a Blossom server returned a blob
* whose sha256 matched the declared [hash]. [bytes] are safe to render.
*/
data class Resolved(
val path: String,
val hash: HexKey,
val contentType: String,
val bytes: ByteArray,
val server: String,
) : StaticSiteResolution {
// ByteArray needs structural equals/hashCode.
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Resolved) return false
return path == other.path &&
hash == other.hash &&
contentType == other.contentType &&
server == other.server &&
bytes.contentEquals(other.bytes)
}
override fun hashCode(): Int {
var result = path.hashCode()
result = 31 * result + hash.hashCode()
result = 31 * result + contentType.hashCode()
result = 31 * result + server.hashCode()
result = 31 * result + bytes.contentHashCode()
return result
}
}
/** No `path` tag in the manifest matches the request path. */
data object PathNotInManifest : StaticSiteResolution
/**
* The path exists in the manifest but no listed server returned a blob whose hash
* matched [hash] — every candidate was unreachable, errored, or served tampered bytes.
*/
data class Unresolvable(
val hash: HexKey,
) : StaticSiteResolution
}
/**
* Resolves request paths for a NIP-5A static-website / napplet manifest into verified
* blob bytes, fetched over Blossom.
*
* The trust model is the whole point: the **manifest is the authority and the Blossom
* server is untrusted**. The signed manifest pins each path to a sha256; this resolver
* downloads the content-addressed blob from each listed server in order and accepts the
* first one whose recomputed sha256 matches the pin. A server that substitutes, corrupts,
* or truncates a blob fails [verify] and is silently skipped — it can withhold content but
* can never forge it. This is what lets a napplet shell run third-party code from an
* untrusted CDN behind a single signed, content-addressed manifest.
*/
object StaticSiteResolver {
/** True iff [blob]'s sha256 equals [expectedHash] (case-insensitive hex). */
fun verify(
blob: ByteArray,
expectedHash: HexKey,
): Boolean = sha256(blob).toHexKey().equals(expectedHash, ignoreCase = true)
/**
* Ordered candidate Blossom URLs for [hash] across [servers]. Blossom addresses blobs
* by bare sha256 (`<server>/<sha256>`), so the path's extension is irrelevant here.
*/
fun candidateUrls(
servers: List<String>,
hash: HexKey,
): List<String> = servers.map { "${it.trimEnd('/')}/$hash" }
/**
* Resolves [requestPath] against the manifest's [paths] and [servers], fetching with
* [fetch] and verifying every downloaded blob's hash before returning it.
*
* @param paths the manifest's `path` tags (`event.paths()`).
* @param servers the manifest's `server` tags (`event.servers()`), tried in order.
* Additional fallbacks (e.g. the author's kind:10063 Blossom list) can
* be appended by the caller before invoking this function.
*/
suspend fun resolve(
requestPath: String,
paths: List<PathTag>,
servers: List<String>,
fetch: BlobFetcher,
): StaticSiteResolution {
val match = paths.resolvePath(requestPath) ?: return StaticSiteResolution.PathNotInManifest
for (url in candidateUrls(servers, match.hash)) {
val bytes =
try {
fetch(url)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
null
} ?: continue
if (verify(bytes, match.hash)) {
return StaticSiteResolution.Resolved(
path = match.path,
hash = match.hash,
contentType = guessStaticContentType(match.path),
bytes = bytes,
server = url.substringBeforeLast('/'),
)
}
}
return StaticSiteResolution.Unresolvable(match.hash)
}
}
@@ -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,162 @@
/*
* 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.resolver
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
class StaticSiteResolverTest {
private fun bytes(s: String) = s.encodeToByteArray()
private fun hashOf(s: String) = sha256(bytes(s)).toHexKey()
@Test
fun normalizesRootAndDirectoryAndQueryPaths() {
assertEquals("index.html", normalizeStaticPath(""))
assertEquals("index.html", normalizeStaticPath("/"))
assertEquals("app.js", normalizeStaticPath("/app.js"))
assertEquals("app.js", normalizeStaticPath("./app.js"))
assertEquals("assets/app.js", normalizeStaticPath("/assets/app.js?v=2#top"))
assertEquals("docs/index.html", normalizeStaticPath("/docs/"))
}
@Test
fun lookupMatchesRegardlessOfLeadingSlash() {
val paths =
listOf(
PathTag("/index.html", hashOf("home")),
PathTag("assets/app.js", hashOf("script")),
)
assertEquals(hashOf("home"), paths.resolvePath("/")?.hash)
assertEquals(hashOf("home"), paths.resolvePath("/index.html")?.hash)
assertEquals(hashOf("script"), paths.resolvePath("assets/app.js")?.hash)
assertEquals(null, paths.resolvePath("missing.js"))
}
@Test
fun guessesWebContentTypes() {
assertEquals("text/html; charset=utf-8", guessStaticContentType("index.html"))
assertEquals("text/javascript; charset=utf-8", guessStaticContentType("app.mjs"))
assertEquals("application/wasm", guessStaticContentType("core.wasm"))
assertEquals("application/octet-stream", guessStaticContentType("blob.unknownext"))
}
@Test
fun verifyAcceptsMatchingAndRejectsTamperedBytes() {
val good = bytes("<html>napplet</html>")
val hash = sha256(good).toHexKey()
assertTrue(StaticSiteResolver.verify(good, hash))
assertFalse(StaticSiteResolver.verify(bytes("<html>evil</html>"), hash))
}
@Test
fun resolvesFromTheFirstServerThatServesMatchingBytes() =
runTest {
val html = "<html>home</html>"
val paths = listOf(PathTag("/index.html", hashOf(html)))
val resolution =
StaticSiteResolver.resolve(
requestPath = "/",
paths = paths,
servers = listOf("https://cdn.example.com", "https://backup.example.com"),
fetch = { url -> if (url.startsWith("https://cdn.example.com")) bytes(html) else null },
)
val resolved = assertIs<StaticSiteResolution.Resolved>(resolution)
// Resolved.path echoes the manifest's declared path verbatim, not the normalized request.
assertEquals("/index.html", resolved.path)
assertEquals("https://cdn.example.com", resolved.server)
assertEquals("text/html; charset=utf-8", resolved.contentType)
assertEquals(html, resolved.bytes.decodeToString())
}
@Test
fun skipsAServerThatTampersWithTheBlobAndFallsThroughToAnHonestOne() =
runTest {
val html = "<html>home</html>"
val paths = listOf(PathTag("/index.html", hashOf(html)))
var triedMalicious = false
val resolution =
StaticSiteResolver.resolve(
requestPath = "/",
paths = paths,
servers = listOf("https://evil.example.com", "https://honest.example.com"),
fetch = { url ->
if (url.startsWith("https://evil.example.com")) {
triedMalicious = true
bytes("<html>injected malware</html>")
} else {
bytes(html)
}
},
)
val resolved = assertIs<StaticSiteResolution.Resolved>(resolution)
// The tampered server was contacted but its bytes were rejected by hash verification...
assertTrue(triedMalicious)
// ...and resolution fell through to the honest server's verified copy.
assertEquals("https://honest.example.com", resolved.server)
assertEquals(html, resolved.bytes.decodeToString())
}
@Test
fun reportsUnresolvableWhenEveryServerFailsVerification() =
runTest {
val paths = listOf(PathTag("/index.html", hashOf("real")))
val resolution =
StaticSiteResolver.resolve(
requestPath = "/",
paths = paths,
servers = listOf("https://a.example.com", "https://b.example.com"),
fetch = { _ -> bytes("tampered") },
)
assertEquals(StaticSiteResolution.Unresolvable(hashOf("real")), resolution)
}
@Test
fun reportsPathNotInManifestForUndeclaredPaths() =
runTest {
val paths = listOf(PathTag("/index.html", hashOf("home")))
val resolution =
StaticSiteResolver.resolve(
requestPath = "/secret.js",
paths = paths,
servers = listOf("https://a.example.com"),
fetch = { _ -> error("should not fetch for an undeclared path") },
)
assertEquals(StaticSiteResolution.PathNotInManifest, resolution)
}
}
@@ -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 })
}
}