feat(quartz): add NIP-5A static-site / napplet resolver

Adds a platform-agnostic resolver for NIP-5A static-website / napplet (NIP-5D)
manifests in quartz commonMain, under nip5aStaticWebsites/resolver/:

- StaticSitePathLookup: request-path normalization (query/fragment stripping,
  leading-slash insensitivity, root/dir -> index.html), slash-insensitive
  path lookup over a manifest's path tags, and a web-asset Content-Type guess.
- StaticSiteResolver: hash verification, Blossom candidate-URL assembly, and a
  suspend resolve() that downloads each listed server in order and accepts the
  first blob whose recomputed sha256 matches the manifest pin. HTTP is injected
  via a BlobFetcher typealias so quartz keeps no HTTP dependency.

The trust model is the point: the signed manifest is the authority, the Blossom
server is untrusted. A server that substitutes/corrupts a blob fails
verification and is skipped -- it can withhold content but never forge it.
Tests cover normalization, lookup, MIME guessing, and the security cases
(tampered server skipped -> falls through to honest server; all-tampered ->
Unresolvable; undeclared path -> PathNotInManifest without fetching).

Also adds quartz/plans/2026-06-19-napplet-nip5a-resolver.md documenting the
design and the open event-shape alignment questions (35128 vs 35129 manifest
kind, capability declaration vs NIP-89, aggregate build hash, server ordering)
to raise with the napplet author before the nsite/napplet event shape forks.

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 20:08:27 +00:00
parent 1bfdf02884
commit b547b07747
4 changed files with 516 additions and 0 deletions
@@ -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,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,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)
}
}