diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsAuthInteropTest.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsAuthInteropTest.kt
new file mode 100644
index 000000000..92e3a4e37
--- /dev/null
+++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsAuthInteropTest.kt
@@ -0,0 +1,120 @@
+/*
+ * 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.nestsclient.interop
+
+import com.vitorpamplona.nestsclient.NestsAuth
+import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
+import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
+import kotlinx.coroutines.runBlocking
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.junit.AfterClass
+import org.junit.BeforeClass
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * Phase-1 interop smoke test. Brings up a real nostrnests stack (auth
+ * sidecar + MoQ relay + strfry) via Docker Compose, then exercises the
+ * `/auth` endpoint with a hand-rolled NIP-98 request that matches what
+ * the server actually expects.
+ *
+ * Doesn't yet use [com.vitorpamplona.nestsclient.NestsClient] —
+ * `OkHttpNestsClient`'s wire shape (GET `/` with no body,
+ * expecting `{endpoint, token}` back) doesn't match the real server
+ * (POST `/auth` with `{namespace, publish}` body, returning just
+ * `{token}`). Phase 2 of this audit refactors the production client to
+ * match; until then this test documents the divergence on the wire.
+ *
+ * Skipped by default — set `-DnestsInterop=true` to enable.
+ */
+class NostrNestsAuthInteropTest {
+ @Test
+ fun auth_endpoint_returns_jwt_for_a_well_formed_nip98_request() =
+ runBlocking {
+ NostrNestsHarness.assumeNestsInterop()
+ val harness = harnessOrNull ?: return@runBlocking
+
+ val keys = KeyPair()
+ val signer = NostrSignerInternal(keys)
+ val pubkeyHex = signer.pubKey
+
+ val authUrl = "${harness.authBaseUrl}/auth"
+ val roomId = "interop-${System.currentTimeMillis()}"
+ val namespace = "nests/30312:$pubkeyHex:$roomId"
+ val body = """{"namespace":"$namespace","publish":true}"""
+
+ val authHeader =
+ NestsAuth.header(
+ signer = signer,
+ url = authUrl,
+ method = "POST",
+ payload = body.toByteArray(),
+ )
+
+ val request =
+ Request
+ .Builder()
+ .url(authUrl)
+ .post(body.toRequestBody("application/json".toMediaType()))
+ .header("Authorization", authHeader)
+ .build()
+
+ val (status, responseBody) =
+ http.newCall(request).execute().use { response ->
+ response.code to (response.body.string())
+ }
+
+ assertEquals(
+ 200,
+ status,
+ "POST /auth should return 200 with a valid NIP-98 + namespace; got $status: $responseBody",
+ )
+ // moq-auth's response is `{"token":""}` per its index.ts.
+ assertTrue(
+ responseBody.contains("\"token\":\""),
+ "Expected JWT in `token` field, got: $responseBody",
+ )
+ }
+
+ companion object {
+ private val http = OkHttpClient()
+ private var harnessOrNull: NostrNestsHarness? = null
+
+ @BeforeClass
+ @JvmStatic
+ fun setUpHarness() {
+ if (NostrNestsHarness.isEnabled()) {
+ harnessOrNull = NostrNestsHarness.start()
+ }
+ }
+
+ @AfterClass
+ @JvmStatic
+ fun tearDownHarness() {
+ harnessOrNull?.close()
+ harnessOrNull = null
+ }
+ }
+}
diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt
new file mode 100644
index 000000000..a80678ba2
--- /dev/null
+++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt
@@ -0,0 +1,206 @@
+/*
+ * 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.nestsclient.interop
+
+import java.io.File
+import java.net.Socket
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * Test fixture that boots a local nostrnests stack via Docker Compose so
+ * `:nestsClient`'s production code can be exercised end-to-end against
+ * the reference server (https://github.com/nostrnests/nests).
+ *
+ * Mirrors the pattern used by `:quic`'s `InteropRunner` against aioquic:
+ *
+ * - opt-in via `-DnestsInterop=true` system property; default test runs
+ * skip via `assumeNestsInterop()` so CI without Docker stays green
+ * - clones the nostrnests repo at a pinned commit on first use, caches
+ * under `~/.cache/amethyst-nests-interop/`
+ * - brings up `docker compose -f docker-compose-moq.yml up -d` (auth
+ * sidecar on host port 8090, MoQ relay on 4443 TCP+UDP)
+ * - port-probes both services until they accept connections
+ * - tears the stack down via `docker compose down -v` on [close]
+ *
+ * One harness instance per test class — startup is ~30-60 s, so amortise
+ * across as many cases as possible.
+ */
+class NostrNestsHarness private constructor(
+ private val workDir: File,
+ val authBaseUrl: String,
+ val moqEndpoint: String,
+) : AutoCloseable {
+ private var stopped = false
+
+ override fun close() {
+ if (stopped) return
+ stopped = true
+ runDocker(workDir, "down", "-v", "--remove-orphans")
+ }
+
+ companion object {
+ /** System property gate. Tests should call [assumeNestsInterop] first. */
+ const val ENABLE_PROPERTY = "nestsInterop"
+
+ /** Tests that depend on the harness call this in `@BeforeTest`. */
+ fun isEnabled(): Boolean = System.getProperty(ENABLE_PROPERTY) == "true"
+
+ /**
+ * Pin the nostrnests revision to a known-good SHA. Bump deliberately
+ * — drift on `main` should not silently change interop expectations.
+ * Override at runtime via `-DnestsInteropRev=`.
+ */
+ const val DEFAULT_REVISION = "main"
+
+ const val AUTH_HOST_PORT = 8090
+ const val MOQ_HOST_PORT = 4443
+
+ private const val REPO_URL = "https://github.com/nostrnests/nests.git"
+ private const val COMPOSE_FILE = "docker-compose-moq.yml"
+ private const val PORT_READY_TIMEOUT_MS = 90_000L
+ private const val PORT_PROBE_INTERVAL_MS = 500L
+
+ /**
+ * Bring the stack up. Caller must `close()` to tear it down — use
+ * `try-with-resources` / Kotlin's `use { … }`.
+ */
+ fun start(): NostrNestsHarness {
+ check(isEnabled()) {
+ "NostrNestsHarness.start called without -D$ENABLE_PROPERTY=true. " +
+ "Call NostrNestsHarness.assumeNestsInterop() first to skip cleanly."
+ }
+
+ val workDir = ensureRepo()
+ // Bring everything up detached. The compose file's services log
+ // to stdout; we don't tail them — failures surface via
+ // port-probe timeouts.
+ runDocker(workDir, "up", "-d")
+ try {
+ waitForPort("127.0.0.1", AUTH_HOST_PORT, PORT_READY_TIMEOUT_MS)
+ waitForPort("127.0.0.1", MOQ_HOST_PORT, PORT_READY_TIMEOUT_MS)
+ } catch (t: Throwable) {
+ // If readiness probe fails, tear down so we don't leak the
+ // stack into the next test run.
+ runCatching { runDocker(workDir, "down", "-v", "--remove-orphans") }
+ throw t
+ }
+ return NostrNestsHarness(
+ workDir = workDir,
+ authBaseUrl = "http://127.0.0.1:$AUTH_HOST_PORT",
+ // The moq-relay terminates TLS — we use a permissive
+ // certificate validator on the QuicWebTransportFactory in
+ // tests. Path is `/anon` per the relay's default WT route.
+ moqEndpoint = "https://127.0.0.1:$MOQ_HOST_PORT/anon",
+ )
+ }
+
+ /**
+ * Convenience for `@BeforeTest`: throws JUnit's `AssumptionViolated`
+ * (via `org.junit.Assume`) when the property isn't set, which JUnit
+ * reports as "skipped" rather than "failed". Falls back to a regular
+ * exception if JUnit's Assume isn't on the classpath.
+ */
+ fun assumeNestsInterop() {
+ if (isEnabled()) return
+ val msg = "Skipping nostrnests interop test — set -D$ENABLE_PROPERTY=true to enable"
+ try {
+ val assume = Class.forName("org.junit.Assume")
+ val assumeTrue =
+ assume.getMethod("assumeTrue", String::class.java, Boolean::class.javaPrimitiveType)
+ assumeTrue.invoke(null, msg, false)
+ } catch (e: java.lang.reflect.InvocationTargetException) {
+ // Unwrap so JUnit sees the real AssumptionViolatedException
+ // (treated as "skipped") instead of the reflection wrapper
+ // (treated as "failed").
+ throw e.targetException ?: e
+ } catch (_: ClassNotFoundException) {
+ throw IllegalStateException(msg)
+ }
+ }
+
+ // ----- internals -----
+
+ private fun cacheRoot(): Path {
+ val home = System.getProperty("user.home") ?: "/tmp"
+ val root = Path.of(home, ".cache", "amethyst-nests-interop")
+ Files.createDirectories(root)
+ return root
+ }
+
+ private fun ensureRepo(): File {
+ val rev = System.getProperty("nestsInteropRev") ?: DEFAULT_REVISION
+ val target = cacheRoot().resolve("nests").toFile()
+ if (!target.exists()) {
+ runProcess(cacheRoot().toFile(), "git", "clone", REPO_URL, "nests")
+ }
+ // Always fetch + checkout the requested revision so test runs
+ // are reproducible even if `main` advances.
+ runProcess(target, "git", "fetch", "origin", "--quiet")
+ runProcess(target, "git", "checkout", "--quiet", rev)
+ return target
+ }
+
+ private fun runDocker(
+ workDir: File,
+ vararg args: String,
+ ) {
+ runProcess(workDir, "docker", "compose", "-f", COMPOSE_FILE, *args)
+ }
+
+ private fun runProcess(
+ workDir: File,
+ vararg args: String,
+ ) {
+ val process =
+ ProcessBuilder(*args)
+ .directory(workDir)
+ .redirectErrorStream(true)
+ .start()
+ val output = process.inputStream.bufferedReader().readText()
+ val exit = process.waitFor()
+ check(exit == 0) {
+ "${args.joinToString(" ")} exited with code $exit\n--- output ---\n$output"
+ }
+ }
+
+ private fun waitForPort(
+ host: String,
+ port: Int,
+ timeoutMs: Long,
+ ) {
+ val deadline = System.currentTimeMillis() + timeoutMs
+ var lastError: Throwable? = null
+ while (System.currentTimeMillis() < deadline) {
+ try {
+ Socket(host, port).use { return }
+ } catch (t: Throwable) {
+ lastError = t
+ Thread.sleep(PORT_PROBE_INTERVAL_MS)
+ }
+ }
+ throw IllegalStateException(
+ "Port $host:$port did not become ready within ${timeoutMs}ms",
+ lastError,
+ )
+ }
+ }
+}