From 7bc95d836cabeafa14f070bc1db2942e71bb3fd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 15:35:14 +0000 Subject: [PATCH] feat(amy): nsite/napplet serve (local preview); drop standalone publish.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `amy nsite serve` and `amy napplet serve [--d ID] [--port N]`: fetch the manifest and serve its content over a local HTTP server, resolving each request through quartz StaticSiteResolver (blob downloaded from Blossom and sha256-verified per request, same as the device host) with SPA fallback to index.html. Lets you open a published site/napplet in a browser to confirm it loads and routes. (Static content only — a napplet's window.napplet.* runtime still needs the Amethyst host; documented in the command + harness README.) Implemented as thin cli glue (StaticSiteServe) over the resolver + commons BlossomClient + the JDK HTTP server. Also retire tools/napplet-test/publish.sh now that `amy napplet publish` is the single source of truth; the harness README documents publish + serve via amy. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde --- .../amethyst/cli/commands/NappletCommands.kt | 39 +++++- .../amethyst/cli/commands/NsiteCommands.kt | 38 +++++- .../amethyst/cli/commands/StaticSiteServe.kt | 115 ++++++++++++++++++ tools/napplet-test/README.md | 50 +++----- tools/napplet-test/publish.sh | 96 --------------- 5 files changed, 209 insertions(+), 129 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSiteServe.kt delete mode 100755 tools/napplet-test/publish.sh diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt index c9ff43a117..489a42b147 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt @@ -57,13 +57,50 @@ object NappletCommands { route( "napplet", tail, - "napplet …", + "napplet …", mapOf( "fetch" to { rest -> fetch(dataDir, rest) }, "publish" to { rest -> publish(dataDir, rest) }, + "serve" to { rest -> serve(dataDir, rest) }, ), ) + /** + * `amy napplet serve [--d ID] [--port N]` — fetch + aggregate-verify the manifest and + * serve its static content over a local HTTP server. NOTE: the napplet's window.napplet.* runtime + * needs the Amethyst host; this serves the files only, for inspecting that they load/route. + */ + private suspend fun serve( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val author = args.positionalOrNull(0) ?: return Output.error("bad_args", "napplet serve [--d ID] [--port N] [--server S] [--relay R]") + val identifier = args.flag("d") + val port = args.intFlag("port", 8080) + val extraServers = StaticSiteFetch.commaList(args.flag("server")) + val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) + val timeoutSecs = args.longFlag("timeout", 8L) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val authorHex = ctx.requireUserHex(author) + val relays = + extraRelays + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + .ifEmpty { ctx.bootstrapRelays() } + val event = + fetchByAuthor(ctx, authorHex, identifier, relays, timeoutSecs * 1000) + ?: return Output.error("not_found", "no napplet manifest found", mapOf("pubkey" to authorHex, "d" to identifier)) + val manifest = event as NappletManifest + if (!manifest.verifyAggregate()) { + return Output.error("aggregate_mismatch", "manifest x aggregate does not match its path tags") + } + return StaticSiteServe.serve(manifest.paths(), (manifest.servers() + extraServers).distinct(), port) + } + } + /** * `amy napplet publish --server [--requires identity,relay,…] [--d ID] [--relay …]` * — upload a napplet directory to Blossom and broadcast its NIP-5D manifest (kind 15129 root, or 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 index 92d5e184db..b0b8dc51e7 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt @@ -57,13 +57,49 @@ object NsiteCommands { route( "nsite", tail, - "nsite …", + "nsite …", mapOf( "fetch" to { rest -> fetch(dataDir, rest) }, "publish" to { rest -> publish(dataDir, rest) }, + "serve" to { rest -> serve(dataDir, rest) }, ), ) + /** + * `amy nsite serve [--d ID] [--port N]` — fetch the manifest and serve it over a local + * HTTP server (sha256-verified per request) so you can open it in a browser. + */ + private suspend fun serve( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val author = args.positionalOrNull(0) ?: return Output.error("bad_args", "nsite serve [--d ID] [--port N] [--server S] [--relay R]") + val identifier = args.flag("d") + val port = args.intFlag("port", 8080) + val extraServers = StaticSiteFetch.commaList(args.flag("server")) + val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) + val timeoutSecs = args.longFlag("timeout", 8L) + + Context.open(dataDir).use { ctx -> + 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) + ?: return Output.error( + "not_found", + "no static-website manifest for this author", + mapOf("pubkey" to authorHex, "d" to identifier), + ) + return StaticSiteServe.serve(manifest.paths, (manifest.servers + extraServers).distinct(), port) + } + } + /** * `amy nsite publish --server [--d ID] [--relay …] [--title …]` — upload a static * site directory to Blossom and broadcast its NIP-5A manifest (kind 15128 root, or 35128 named diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSiteServe.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSiteServe.kt new file mode 100644 index 0000000000..2ec786673f --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSiteServe.kt @@ -0,0 +1,115 @@ +/* + * 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.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +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 kotlinx.coroutines.runBlocking +import java.net.InetSocketAddress +import java.util.concurrent.CountDownLatch + +/** + * Serves a resolved NIP-5A/5D manifest over a local HTTP server so you can open it in a browser. Each + * request is resolved through quartz's [StaticSiteResolver] — the blob is downloaded from the + * manifest's Blossom servers and **sha256-verified** against the pin before it is served, exactly as + * the on-device host does. SPA fallback to `index.html` mirrors the host's behavior. + * + * Thin assembly over the resolver + commons [BlossomClient] + the JDK HTTP server. NOTE: this serves + * the **static content** only — a napplet's `window.napplet.*` runtime needs the Android/desktop shell + * + broker, which this does not provide; use it to inspect that the files load and route correctly. + */ +internal object StaticSiteServe { + suspend fun serve( + paths: List, + servers: List, + port: Int, + ): Int { + if (servers.isEmpty()) return Output.error("no_servers", "manifest lists no Blossom servers; pass --server URL") + + val blossom = BlossomClient() + val server = HttpServer.create(InetSocketAddress("127.0.0.1", port), 0) + server.createContext("/") { exchange -> handle(exchange, paths, servers, blossom) } + server.executor = null // default per-request executor — fine for a single-user dev server + server.start() + + val actualPort = server.address.port + Output.emit( + mapOf( + "serving" to "http://127.0.0.1:$actualPort/", + "port" to actualPort, + "paths" to paths.size, + "servers" to servers, + "note" to "static content only; napplet runtime APIs need the Amethyst host. Ctrl+C to stop.", + ), + ) + + // Block until the process is interrupted (Ctrl+C), then stop cleanly. + val done = CountDownLatch(1) + Runtime.getRuntime().addShutdownHook( + Thread { + server.stop(0) + done.countDown() + }, + ) + runCatching { done.await() } + return 0 + } + + private fun handle( + exchange: HttpExchange, + paths: List, + servers: List, + blossom: BlossomClient, + ) { + exchange.use { + if (!exchange.requestMethod.equals("GET", ignoreCase = true)) { + exchange.sendResponseHeaders(405, -1) + return@use + } + val requestPath = exchange.requestURI.path + val fetch: suspend (String) -> ByteArray? = { url -> blossom.download(url) } + + var resolution = runBlocking { StaticSiteResolver.resolve(requestPath, paths, servers, fetch) } + // SPA fallback: an unknown route falls back to the verified index.html (browsers send Accept: text/html). + if (resolution !is StaticSiteResolution.Resolved && requestPath != "/" && acceptsHtml(exchange)) { + resolution = runBlocking { StaticSiteResolver.resolve("/", paths, servers, fetch) } + } + + if (resolution is StaticSiteResolution.Resolved) { + exchange.responseHeaders.set("Content-Type", resolution.contentType) + exchange.sendResponseHeaders(200, resolution.bytes.size.toLong()) + exchange.responseBody.use { it.write(resolution.bytes) } + } else { + val body = "Not found: $requestPath".encodeToByteArray() + exchange.responseHeaders.set("Content-Type", "text/plain") + exchange.sendResponseHeaders(404, body.size.toLong()) + exchange.responseBody.use { it.write(body) } + } + } + } + + private fun acceptsHtml(exchange: HttpExchange): Boolean = exchange.requestHeaders.getFirst("Accept")?.contains("text/html", ignoreCase = true) == true +} diff --git a/tools/napplet-test/README.md b/tools/napplet-test/README.md index d322e5f538..48cf9ff7df 100644 --- a/tools/napplet-test/README.md +++ b/tools/napplet-test/README.md @@ -5,19 +5,16 @@ A self-contained napplet for **on-device verification** of Amethyst's NIP-5D hos shim → shell → broker → consent round-trip (and the newer `identity.getList/getZaps/getBadges`, `identity.onChanged`, `keys.onAction`, and `resource.bytes` `nostr:` paths) works on a real device. -## Files +`index.html` is the napplet: read-only checks run on load; publish/upload/pay are behind buttons; live +pushes (`identity.changed`, `keys.action`) land in the top banner. -- `index.html` — the napplet. Read-only checks run on load; publish/upload/pay are behind buttons; - live pushes (`identity.changed`, `keys.action`) land in the top banner. -- `publish.sh` — a standalone (nak + curl) uploader, for when you don't want to build `amy`. +## Publish it -## Publish with `amy` (recommended) - -`amy napplet publish` uploads the whole directory to Blossom and broadcasts the NIP-5D event in one -step, using the account `amy` is logged in as: +`amy napplet publish` uploads the whole directory to Blossom (BUD-02 signed) and broadcasts the NIP-5D +event in one step, as the account `amy` is logged in as: ```bash -./gradlew :cli:installDist # builds amy +./gradlew :cli:installDist # builds amy → cli/build/install/amy/bin/amy amy napplet publish tools/napplet-test \ --server https://blossom.primal.net \ --requires identity,relay,storage,value,resource,upload,keys \ @@ -25,33 +22,26 @@ amy napplet publish tools/napplet-test \ --relay wss://relay.damus.io --relay wss://nos.lol ``` -(`--d` makes it an addressable kind-35129 napplet; omit it for a root kind-15129. For a plain static -site use `amy nsite publish --server …`.) Use the **same key you're logged in as in Amethyst**. +- `--d` makes it an addressable kind-35129 napplet; omit it for a root kind-15129. +- For a plain static site, use `amy nsite publish --server …` (kind 15128/35128). +- Use the **same key you're logged in as in Amethyst**, so the napplet appears under your account and + the identity reads (`getProfile`, `getFollows`, …) have data. -## Prerequisites +Verify it resolves (optional): `amy napplet fetch --d napplet-test`. -- [`nak`](https://github.com/fiatjaf/nak) (signs + publishes the events), `curl`, and `sha256sum` - (or `shasum`/`openssl`). -- A Blossom server that accepts BUD-02 uploads (e.g. `https://blossom.primal.net`, - `https://cdn.satellite.earth`, or your own). -- Your **nsec** — use the **same key you're logged in as in Amethyst**, so the napplet appears under - your account and the identity reads (`getProfile`, `getFollows`, …) have data. +## Preview in a browser (optional) -## Publish without `amy` (standalone) +`amy napplet serve` resolves the published manifest and serves its static content locally (each blob +sha256-verified, just like the device host) so you can eyeball that it loads: ```bash -cd tools/napplet-test -./publish.sh --sec nsec1yourkey... --server https://blossom.primal.net \ - --relay wss://relay.damus.io --relay wss://nos.lol +amy napplet serve --d napplet-test --port 8080 # then open http://127.0.0.1:8080 ``` -Then verify it resolves (optional): +This serves the **files only** — the `window.napplet.*` runtime needs the Amethyst host, so the API +rows won't pass in a plain browser. Use it to confirm the files publish and route correctly. -```bash -amy napplet fetch --d napplet-test -``` - -## Open it in Amethyst +## Run on a device Build & install the debug app and watch the logs: @@ -82,6 +72,4 @@ feed card) and tap **Open**. It launches in the sandboxed `:napplet` process. ## Notes -- `publish.sh` uses `nak`'s `-t key=val1;val2` multi-element tag syntax (e.g. - `path=/index.html;`). If your `nak` version differs, adjust accordingly. -- Re-running `publish.sh` replaces the same addressable event (`d=napplet-test`). +- Re-running `amy napplet publish … --d napplet-test` replaces the same addressable event. diff --git a/tools/napplet-test/publish.sh b/tools/napplet-test/publish.sh deleted file mode 100755 index 8d8f6e109b..0000000000 --- a/tools/napplet-test/publish.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env bash -# -# Publish the test napplet (index.html) so it can be opened in Amethyst on a device: -# 1. uploads index.html to a Blossom server (BUD-02 signed upload), -# 2. publishes a NIP-5D named-napplet event (kind 35129) whose manifest pins -# /index.html to the blob's sha256 and declares the capabilities it uses. -# -# Requirements: nak (https://github.com/fiatjaf/nak), curl, and sha256sum (or shasum/openssl). -# -# Usage: -# ./publish.sh --sec nsec1... --server https://blossom.example [--relay wss://... ]... [--id napplet-test] -# -# The secret key can also come from $NOSTR_SECRET_KEY or $NSEC. Relays default to a couple of -# public ones if none are given. Use the SAME key you are logged in as in Amethyst, so the napplet -# shows up under your own account and the identity reads have data. -set -euo pipefail - -HERE="$(cd "$(dirname "$0")" && pwd)" -HTML="$HERE/index.html" -ID="napplet-test" -SERVER="" -SEC="${NOSTR_SECRET_KEY:-${NSEC:-}}" -RELAYS=() - -while [ $# -gt 0 ]; do - case "$1" in - --sec) SEC="$2"; shift 2 ;; - --server) SERVER="${2%/}"; shift 2 ;; - --relay) RELAYS+=("$2"); shift 2 ;; - --id) ID="$2"; shift 2 ;; - -h|--help) sed -n '2,20p' "$0"; exit 0 ;; - *) echo "Unknown arg: $1" >&2; exit 2 ;; - esac -done - -[ -n "$SEC" ] || { echo "ERROR: no secret key (--sec / \$NOSTR_SECRET_KEY / \$NSEC)" >&2; exit 2; } -[ -n "$SERVER" ] || { echo "ERROR: no --server (a Blossom base URL, e.g. https://blossom.primal.net)" >&2; exit 2; } -command -v nak >/dev/null || { echo "ERROR: nak not found (https://github.com/fiatjaf/nak)" >&2; exit 2; } -command -v curl >/dev/null || { echo "ERROR: curl not found" >&2; exit 2; } -[ ${#RELAYS[@]} -gt 0 ] || RELAYS=(wss://relay.damus.io wss://nos.lol) - -sha256() { - if command -v sha256sum >/dev/null; then sha256sum | cut -d' ' -f1 - elif command -v shasum >/dev/null; then shasum -a 256 | cut -d' ' -f1 - else openssl dgst -sha256 | sed 's/.* //'; fi -} -b64() { base64 | tr -d '\n'; } - -HASH="$(sha256 < "$HTML")" -echo "index.html sha256 : $HASH" - -# --- 1. Blossom upload (BUD-02: a kind-24242 'upload' auth event in the Authorization header) --- -EXP=$(( $(date +%s) + 3600 )) -AUTH_JSON="$(nak event -k 24242 --sec "$SEC" -t "t=upload" -t "x=$HASH" -t "expiration=$EXP" -c "Upload napplet test")" -AUTH_B64="$(printf '%s' "$AUTH_JSON" | b64)" - -echo "Uploading to $SERVER/upload …" -UP="$(curl -sS -X PUT "$SERVER/upload" \ - -H "Authorization: Nostr $AUTH_B64" \ - -H "Content-Type: text/html" \ - --data-binary @"$HTML")" -echo " server said: $UP" - -echo "Verifying $SERVER/$HASH is retrievable …" -CODE="$(curl -sS -o /dev/null -w '%{http_code}' "$SERVER/$HASH")" -[ "$CODE" = "200" ] || { echo "ERROR: blob not retrievable (HTTP $CODE). Check the upload response above." >&2; exit 1; } -echo " OK (HTTP 200)" - -# --- 2. NIP-5A aggregate hash over the one path: sha256(" /index.html\n") --- -AGG="$(printf '%s /index.html\n' "$HASH" | sha256)" -echo "aggregate hash : $AGG" - -# --- 3. Publish the NIP-5D named napplet (kind 35129) --- -echo "Publishing napplet event (kind 35129, d=$ID) to: ${RELAYS[*]}" -nak event -k 35129 --sec "$SEC" \ - -d "$ID" \ - -t "path=/index.html;$HASH" \ - -t "x=$AGG;aggregate" \ - -t "server=$SERVER" \ - -t "requires=identity" \ - -t "requires=relay" \ - -t "requires=storage" \ - -t "requires=value" \ - -t "requires=resource" \ - -t "requires=upload" \ - -t "requires=keys" \ - -t "title=Napplet Test Harness" \ - -t "description=Exercises every napplet.* API for on-device verification." \ - "${RELAYS[@]}" - -PUB="$(nak key public "$SEC")" -echo -echo "Done. Author pubkey: $PUB" -echo "Verify resolution with amy:" -echo " amy napplet fetch $PUB --d $ID" -echo "Then in Amethyst (logged in as this key): open your Apps / Napplets list and tap \"Napplet Test Harness\"."