From 1b2311e75f63fb9c44f70c400f8c82dcc5234881 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 20:46:32 +0000 Subject: [PATCH] feat(cli): add `amy nsite fetch` to resolve + verify static sites / napplets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the quartz NIP-5A resolver end-to-end so it can be exercised against real manifests (interop / agents), without building the security-sensitive WebView shell yet. - commons BlossomClient: add download(url) — a Blossom GET returning raw bytes (null on non-2xx; connection failures propagate so callers try the next server). Does not verify the hash; that is the resolver's job. - cli NsiteCommands: `amy nsite fetch AUTHOR [--d ID] [--path P] [--server …] [--relay …] [--out FILE] [--timeout SECS] [--max-inline-bytes N]`. Fetches the manifest (kind 15128 root, or 35128 named with --d) from relays, then resolves one path through StaticSiteResolver, downloading from the manifest's Blossom servers (plus any --server fallbacks) and accepting only the first blob whose sha256 matches the manifest pin. Emits the verified path's bytes (inlined for small text, or written to --out) with hash/server/content-type, or a structured not_found / path_not_found / unresolvable error. Thin-assembly only: all resolution + verification stays in quartz, the byte fetch in commons. Smoke-tested offline: bad-args, help, and a dead-relay run that resolves cleanly to not_found in both text and --json modes. Also converts the StaticSitePathLookup file-overview KDoc to a plain block comment to satisfy ktlint no-consecutive-comments. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C --- .../com/vitorpamplona/amethyst/cli/Main.kt | 11 + .../amethyst/cli/commands/Commands.kt | 5 + .../amethyst/cli/commands/NsiteCommands.kt | 262 ++++++++++++++++++ .../commons/service/upload/BlossomClient.kt | 24 ++ .../resolver/StaticSitePathLookup.kt | 4 +- 5 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 3ebfac44f2..fcbd727bc7 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -162,6 +162,10 @@ private suspend fun dispatch(argv: Array): Int { Commands.notes(dataDir, tail) } + "nsite" -> { + Commands.nsite(dataDir, tail) + } + "store" -> { Commands.store(dataDir, tail) } @@ -358,6 +362,13 @@ private fun printUsage() { | [--since TS] [--until TS] | [--timeout SECS] | + |Static websites / napplets (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 /) + | |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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 527101e9ef..151af2fafd 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -91,6 +91,11 @@ object Commands { tail: Array, ): Int = NotesCommands.dispatch(dataDir, tail) + suspend fun nsite( + dataDir: DataDir, + tail: Array, + ): Int = NsiteCommands.dispatch(dataDir, tail) + suspend fun store( dataDir: DataDir, tail: Array, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt new file mode 100644 index 0000000000..38a8b79a61 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt @@ -0,0 +1,262 @@ +/* + * 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.amethyst.commons.service.upload.BlossomClient +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.resolver.StaticSiteResolution +import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import java.io.File + +/** + * `amy nsite fetch` — resolve a single path of a NIP-5A static website / napplet + * 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. This is the + * same trust boundary a napplet shell relies on, exercised end-to-end so the + * resolver can be checked against real-world manifests (interop / agents). + * + * Thin-assembly only: all resolution + verification lives in quartz + * (`StaticSiteResolver`) and the byte fetch in commons (`BlossomClient.download`). + */ +object NsiteCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", "nsite …") + 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, + ): Int { + val args = Args(rest) + val author = args.positionalOrNull(0) ?: return Output.error("bad_args", "nsite fetch [--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 = commaList(args.flag("server")) + val extraRelays = 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, + ), + ) + } + + val paths = manifest.paths + // Manifest servers first (author intent), then any --server fallbacks; keep order, dedupe. + val servers = (manifest.servers + extraServers).distinct() + 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, manifest, identifier, outFile, maxInlineBytes) + 0 + } + } + } finally { + ctx.close() + } + } + + private fun emitResolved( + resolved: StaticSiteResolution.Resolved, + requestPath: String, + manifest: SiteManifest, + identifier: String?, + outFile: String?, + maxInlineBytes: Long, + ) { + val base = + linkedMapOf( + "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, + "manifest_event_id" to manifest.id, + "d" to identifier, + ) + + 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) + } + + /** + * 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, + 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.id, event.createdAt, event.paths(), event.servers()) + } else { + null + } + is RootSiteEvent -> + if (identifier == null) { + SiteManifest(event.id, event.createdAt, event.paths(), event.servers()) + } else { + null + } + else -> null + } + + private fun commaList(value: String?): List = + value + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?: emptyList() + + private fun isTextual(contentType: String): Boolean = + contentType.startsWith("text/") || + contentType.startsWith("application/json") || + contentType.startsWith("application/xml") || + contentType.startsWith("image/svg") + + /** + * 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 id: String, + val createdAt: Long, + val paths: List, + val servers: List, + ) +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt index 295165d7e5..32078036c5 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt @@ -81,6 +81,30 @@ open class BlossomClient( } } + /** + * Download a blob from an absolute URL — typically a Blossom GET endpoint + * `/`. 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. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSitePathLookup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSitePathLookup.kt index 22909d86be..2d4f2f2362 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSitePathLookup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSitePathLookup.kt @@ -22,11 +22,11 @@ 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 + * 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. *