mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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")
|
||||
}
|
||||
+24
@@ -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.
|
||||
+66
@@ -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)
|
||||
}
|
||||
+7
@@ -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))
|
||||
|
||||
+3
@@ -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)
|
||||
|
||||
+103
@@ -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"
|
||||
}
|
||||
}
|
||||
+151
@@ -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)
|
||||
}
|
||||
}
|
||||
+51
@@ -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)
|
||||
}
|
||||
}
|
||||
+87
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
@@ -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())
|
||||
}
|
||||
+82
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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)
|
||||
+49
@@ -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)
|
||||
|
||||
+66
@@ -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))
|
||||
}
|
||||
}
|
||||
+162
@@ -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)
|
||||
}
|
||||
}
|
||||
+140
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user