Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd

This commit is contained in:
Claude
2026-07-01 20:30:27 +00:00
14 changed files with 409 additions and 4 deletions
+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
# PreToolUse gate: make sure Kotlin is spotless-clean BEFORE it leaves the box.
#
# Fires on `git push` (Bash tool) and on the create_pull_request MCP tool. Runs
# `spotlessApply`; if that reformats any tracked .kt/.kts file, the push/PR is
# blocked (exit 2) so the agent commits the formatting fix first. This turns
# CI's `spotlessCheck` failure into an in-session block — no red PR, no round
# trip. `spotlessApply` runs the same formatters CI's `spotlessCheck` verifies,
# so a clean apply means a green check.
set -uo pipefail
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
# --- Parse the tool call off stdin; decide whether this call is a boundary. ---
payload="$(cat)"
should_gate="$(
printf '%s' "$payload" | python3 -c '
import json, shlex, sys
try:
data = json.load(sys.stdin)
except Exception:
print("no"); sys.exit(0)
tool = data.get("tool_name", "")
if tool.endswith("create_pull_request"):
print("yes"); sys.exit(0)
if tool != "Bash":
print("no"); sys.exit(0)
cmd = (data.get("tool_input") or {}).get("command", "")
# Tokenize like a shell so `push` inside a quoted commit message or heredoc
# stays one token and is NOT mistaken for the push subcommand.
try:
tokens = shlex.split(cmd, comments=True)
except ValueError:
tokens = cmd.split()
GLOBAL_WITH_ARG = {"-c", "-C", "--namespace", "--git-dir", "--work-tree", "--exec-path"}
for i, t in enumerate(tokens):
if t != "git" and not t.endswith("/git"):
continue
j = i + 1
while j < len(tokens): # skip git global options to reach the subcommand
tok = tokens[j]
if tok in GLOBAL_WITH_ARG:
j += 2; continue
if tok.startswith("-"):
j += 1; continue
break
if j < len(tokens) and tokens[j] == "push":
print("yes"); sys.exit(0)
print("no")
' 2>/dev/null
)"
[ "$should_gate" = "yes" ] || exit 0
# Nothing to format if no Kotlin is tracked/changed at all — cheap early out.
if ! git ls-files --error-unmatch '*.kt' '*.kts' >/dev/null 2>&1; then
exit 0
fi
# Snapshot Kotlin state (vs HEAD, so staged + unstaged both count) before/after
# formatting; any delta means the committed tree wasn't spotless.
before="$(git diff HEAD -- '*.kt' '*.kts' 2>/dev/null | sha1sum)"
log="$(mktemp /tmp/spotless-gate.XXXXXX.log)"
if ! ./gradlew spotlessApply >"$log" 2>&1; then
# Distinguish a formatting failure (block) from Gradle being unable to RUN —
# e.g. deps can't resolve in a restricted sandbox. An infra failure must not
# strand the agent; warn and let CI's spotlessCheck be the backstop.
if grep -qiE "could not resolve|could not (get|download)|handshake|connect timed out|no address|unable to (find|resolve) host|read timed out" "$log"; then
echo "WARN: could not run spotlessApply (Gradle infra/network failure), skipping the formatting gate." >&2
echo " CI's spotlessCheck still enforces formatting on the PR." >&2
rm -f "$log"
exit 0
fi
echo "BLOCKED: spotlessApply failed — fix the build/formatting error before pushing." >&2
echo "----- gradle output (tail) -----" >&2
tail -n 40 "$log" >&2
rm -f "$log"
exit 2
fi
rm -f "$log"
after="$(git diff HEAD -- '*.kt' '*.kts' 2>/dev/null | sha1sum)"
if [ "$before" != "$after" ]; then
echo "BLOCKED: spotlessApply reformatted Kotlin files that were about to be pushed." >&2
echo "The changes below are now in your working tree. Commit them, then retry:" >&2
echo >&2
git diff --name-only HEAD -- '*.kt' '*.kts' >&2
echo >&2
echo " git add -A && git commit -m 'style: apply spotless' && <retry the push>" >&2
echo "(CI runs 'spotlessCheck'; pushing now would fail the lint job.)" >&2
exit 2
fi
exit 0
+12
View File
@@ -1,5 +1,17 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|mcp__github__create_pull_request",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-push-spotless.sh",
"timeout": 180
}
]
}
],
"SessionStart": [
{
"hooks": [
+25 -1
View File
@@ -1,6 +1,6 @@
---
name: nostr-expert
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent), (8) Resolving user input (hex, npub, nprofile, or NIP-05 `name@domain` internet identifiers) to a pubkey. Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
---
# Nostr Protocol Expert (Quartz Implementation)
@@ -15,6 +15,7 @@ Practical patterns for working with Nostr in Quartz, AmethystMultiplatform's KMP
- Finding NIP implementations in quartz/ codebase
- Nostr cryptography (secp256k1 signing, NIP-44 encryption)
- Bech32 encoding/decoding (npub, nsec, note formats)
- Resolving user input (hex / npub / nprofile / NIP-05 `name@domain`) to a pubkey
- Event validation and verification
**For NIP specifications** → Use `nostr-protocol` agent
@@ -424,6 +425,25 @@ when (val entity = Nip19Parser.uriToRoute(input)?.entity) {
}
```
## Resolving User Input to a Pubkey (NIP-05 + NIP-19)
**Before writing any `if (isHex) … else if (npub) … else if ("@" in s) fetchWellKnown()` logic, stop — it already exists.** `resolveUserHexOrNull` in `quartz/nip05DnsIdentifiers/` accepts every identifier form a user might type and returns a 64-hex pubkey.
```kotlin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull
// hex | npub1… | nprofile1… | nsec1… | name@domain.tld → HexKey? (null if unrecognized/lookup fails)
val pubkey = resolveUserHexOrNull(userInput, nip05Client)
```
- Tries the **synchronous** hex/bech32 path first (`decodePublicKeyAsHexOrNull`) — only NIP-05-shaped input hits the network.
- `suspend`; re-throws only `CancellationException`. Pass `nip05Client = null` for offline contexts.
- Build the client with `Nip05Client(fetcher = OkHttpNip05Fetcher { _ -> okHttp })` (see `cli/Context.kt`). The OkHttp fetcher already runs on IO and disables redirects per the NIP-05 spec — don't re-implement the `.well-known/nostr.json` fetch or JSON parse.
- Need only hex/bech32 (no network)? Use `decodePublicKeyAsHexOrNull(input)` directly.
- Need to *verify* a claimed identifier maps back to a pubkey? `nip05Client.verify(Nip05Id.parse(id)!!, pubkey)`.
See `references/nip05-identifiers.md` for the full API surface (`Nip05Id`, `Nip05Client`, `Nip05Parser`, `KeyInfoSet`, Namecoin `.bit`) and the hand-rolled anti-pattern to avoid.
## Event Validation
```kotlin
@@ -552,6 +572,7 @@ Or see `references/nip-catalog.md` for complete catalog.
- **references/event-hierarchy.md** - Event class hierarchy, kind classifications, common types
- **references/tag-patterns.md** - Tag structure, TagArrayBuilder DSL, common tag types, parsing patterns
- **references/nip19-bech32.md** - `Nip19Parser`, `Bech32Util`, `TlvBuilder`, entity types (NPub, NSec, NEvent, NAddress, NProfile, NRelay, NEmbed)
- **references/nip05-identifiers.md** - Resolving any identifier (hex/npub/nprofile/nsec/`name@domain`) to a pubkey via `resolveUserHexOrNull`; `Nip05Client`, `Nip05Id`, `Nip05Parser`, Namecoin `.bit` — and the hand-rolled anti-pattern to avoid
- **references/event-factory.md** - `EventFactory` dispatch pattern and how to register a new kind
- **references/crypto-and-encryption.md** - Event signing/verification, secp256k1 abstraction, NIP-44 encryption, `SharedKeyCache`
- **references/large-cache.md** - `LargeCache<K,V>` expect/actual + `ICacheOperations` functional API
@@ -567,6 +588,9 @@ Or see `references/nip-catalog.md` for complete catalog.
| Verify signature | `event.verify()` | nip01Core/core/ |
| Encrypt (NIP-44) | `Nip44v2.encrypt(...)` | nip44Encryption/ |
| Bech32 encode | `Nip19.npubEncode(...)` | nip19Bech32/ |
| Resolve input → pubkey | `resolveUserHexOrNull(input, nip05Client)` | nip05DnsIdentifiers/ |
| Decode bech32 → pubkey (no net) | `decodePublicKeyAsHexOrNull(input)` | nip19Bech32/ |
| Verify NIP-05 identifier | `nip05Client.verify(Nip05Id.parse(id)!!, hex)` | nip05DnsIdentifiers/ |
| Find NIP | `scripts/nip-lookup.sh <number>` | - |
## Common Event Kinds
@@ -13,7 +13,7 @@ under `experimental/`**. The categorized list below may lag behind —
| 02 | `nip02FollowList/` | ContactListEvent.kt | Follow/contact lists (kind 3) |
| 03 | `nip03Timestamp/` | OpenTimestampsAttestation.kt | Timestamps |
| 04 | `nip04Dm/` | EncryptedDmEvent.kt | Legacy encrypted DMs (deprecated for NIP-17) |
| 05 | `nip05DnsIdentifiers/` | Nip05Verifier.kt | DNS-based verification |
| 05 | `nip05DnsIdentifiers/` | UserHexResolver.kt, Nip05Client.kt | Internet identifiers; `resolveUserHexOrNull` resolves hex/npub/nprofile/`name@domain` → pubkey (see references/nip05-identifiers.md) |
| 06 | `nip06KeyDerivation/` | Mnemonic-related | BIP-39 key derivation |
| 09 | `nip09Deletions/` | DeletionEvent.kt | Event deletion requests (kind 5) |
| 11 | `nip11RelayInfo/` | RelayInformation.kt | Relay metadata |
@@ -0,0 +1,98 @@
# NIP-05: Identifiers → Pubkey Resolution
How Quartz turns anything a human might type — a raw hex pubkey, an `npub`/`nprofile`/`nsec`, or a NIP-05 internet identifier (`alice@domain.tld`) — into a 64-hex Nostr pubkey. **Everything below already exists in `quartz/nip05DnsIdentifiers/`. Do not hand-roll it.**
## TL;DR — the one function you almost always want
```kotlin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull
// hex | npub1… | nprofile1… | nsec1… | name@domain.tld → 64-hex pubkey (or null)
val pubkey: HexKey? = resolveUserHexOrNull(userInput, nip05Client)
```
`resolveUserHexOrNull(input, nip05Client)` (in `UserHexResolver.kt`) is the canonical "accept any identifier form" resolver. It:
- trims input, tries the **synchronous** bech32/hex path first (`decodePublicKeyAsHexOrNull`), so hex/`npub`/`nprofile`/`nsec` never touch the network;
- only issues an HTTPS fetch for genuinely NIP-05-shaped input (`name@domain.tld`), gated by a cheap `looksLikeNip05()` precheck;
- returns `null` on anything unrecognizable or on a failed NIP-05 lookup (network error / no match);
- re-throws **only** `CancellationException`, so it's safe inside structured concurrency.
Pass `nip05Client = null` in pure-offline contexts — NIP-05-shaped inputs then fall through to `null` and no HTTP is attempted.
## ❌ Do not write this (the hand-rolled anti-pattern)
```kotlin
// DON'T. This re-implements resolveUserHexOrNull badly:
// - no nsec support
// - no input validation (accepts IP-literal / malformed domains → spurious fetches)
// - hand-parses JSON instead of using Nip05Parser
// - bespoke httpGet ignores the "MUST NOT follow redirects" rule
// - swallows CancellationException, breaking structured concurrency
fun resolveObserver(input: String): String? {
if (Hex.isHex64(input)) return input.lowercase()
if (input.startsWith("npub1") || input.startsWith("nprofile1")) { /* … */ }
if ("@" in input) return resolveNip05(input) // bespoke well-known fetch
return null
}
```
## ✅ Do this instead
```kotlin
// CLI already exposes it — commands call Context.requireUserHex(input):
val pubHex = resolveUserHexOrNull(input, nip05Client)
?: return Output.error("bad_args", "expected npub, nprofile, 64-hex, or name@domain.tld")
```
## Building an `Nip05Client`
`resolveUserHexOrNull` takes an `INip05Client`. On JVM/Android, wire the OkHttp fetcher (mirror what `cli/Context.kt` does):
```kotlin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher
val nip05Client = Nip05Client(fetcher = OkHttpNip05Fetcher { _ -> okHttpClient })
```
`OkHttpNip05Fetcher` already runs on `Dispatchers.IO` and disables redirects per the NIP-05 spec ("Fetchers MUST ignore any HTTP redirects"). Don't re-implement the fetch.
For tests / offline code, `EmptyNip05Client` is a no-op stub.
## The pieces (all in `quartz/…/nip05DnsIdentifiers/`)
| Type | File | Purpose |
|------|------|---------|
| `resolveUserHexOrNull(input, client?)` | `UserHexResolver.kt` | **Start here.** Any identifier form → 64-hex pubkey, or null. `suspend`. |
| `Nip05Id` | `Nip05Id.kt` | Parsed `name@domain`. `Nip05Id.parse(str)` validates (RFC 5321 local-part + hostname rules, rejects IP literals) and lowercases. `toUserUrl()` / `toDomainUrl()` build the `.well-known/nostr.json` URLs. `toDisplayValue()` collapses the `_` wildcard to just the domain. |
| `INip05Client` / `Nip05Client` | `INip05Client.kt`, `Nip05Client.kt` | Async resolver. `get(id): Nip05KeyInfo?` (pubkey + relays), `verify(id, hex): Boolean`, `load(id): KeyInfoSet?`, `list(domain): KeyInfoSet`, `loadClinkOffer(id): String?`. Auto-routes `.bit` domains to Namecoin. `EmptyNip05Client` = offline no-op. |
| `Nip05Fetcher` / `OkHttpNip05Fetcher` | `Nip05Fetcher.kt`, `OkHttpNip05Fetcher.kt` (jvmAndroid) | Transport SAM. OkHttp actual disables redirects + runs on IO. |
| `Nip05Parser` | `Nip05Parser.kt` | JSON `.well-known/nostr.json` codec: `parseHexKey`, `parseHexKeyAndRelays`, `parse``KeyInfoSet`, `parseClinkOffer`. |
| `Nip05KeyInfo` / `KeyInfoSet` | `Nip05KeyInfo.kt`, `KeyInfoSet.kt` | `Nip05KeyInfo(pubkey, relays)`; `KeyInfoSet(names: Map, relays: Map)` = the full domain listing. |
| `NamecoinNameResolver` | `namecoin/NamecoinNameResolver.kt` | `.bit` / `d/…` / `id/…` blockchain identifiers. `isNamecoinIdentifier(str)`, `resolve(str)`. Invoked automatically by `Nip05Client` — you rarely call it directly. |
## When you only need the pure (synchronous, no-network) part
If the input can only be hex/bech32 (no NIP-05), skip the client entirely:
```kotlin
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
// hex | npub1… | nprofile1… | nsec1… → 64-hex pubkey (or null). No suspend, no network.
val pubkey: HexKey? = decodePublicKeyAsHexOrNull(input)
```
See `references/nip19-bech32.md` for the full bech32 entity story. `resolveUserHexOrNull` is just this function plus the NIP-05 HTTP fallback.
## Verifying a claimed identifier
To confirm a profile's advertised `nip05` actually points back to its pubkey (NIP-05 verification), use `verify`, not `get`:
```kotlin
val ok: Boolean = nip05Client.verify(Nip05Id.parse("alice@domain.tld")!!, profilePubkeyHex)
```
## Tests
`quartz/src/commonTest/…/nip05DnsIdentifiers/Nip05Test.kt` covers parsing, URL construction, case-normalization, CLINK offers, and the validation rejects (IP literals, malformed domains).
+6
View File
@@ -62,6 +62,12 @@ verify_signatures = true
# Require clients to NIP-42 AUTH before REQ/EVENT/COUNT.
require_auth = false
# Advertise NIP-42 AUTH without requiring it: the relay sends the
# challenge and records clients that authenticate (so downstream
# policies can gate on identity), but REQ/EVENT/COUNT still work for
# clients that never AUTH. Ignored when require_auth = true.
# optional_auth = false
# Reject events whose `created_at` is more than this many seconds in
# the future. Enforced by RejectFutureEventsPolicy.
# reject_future_seconds = 1800
@@ -24,10 +24,12 @@ import com.vitorpamplona.geode.config.BannedEntry
import com.vitorpamplona.geode.config.RuntimeConfig
import com.vitorpamplona.geode.config.RuntimeConfigData
import com.vitorpamplona.geode.config.StaticConfig
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.OptionalAuthPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyAuthOnlyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy
@@ -64,6 +66,8 @@ import java.io.File
* --info <file> NIP-11 doc file (overrides [info] section)
* --db <file> sqlite db path (overrides [database].file)
* --auth require NIP-42 AUTH (sets options.require_auth = true)
* --optional-auth advertise NIP-42 AUTH but don't require it (sets
* options.optional_auth = true; ignored when --auth is set)
* --no-verify DO NOT verify event signatures (off by default
* verify is on; use only for trusted-input
* scenarios like fixture replay).
@@ -84,6 +88,9 @@ fun main(args: Array<String>) {
val cliInfoFile = a.opt("--info")?.let { File(it) }
val dbFile = a.opt("--db") ?: config.database.file?.takeUnless { config.database.in_memory }
val requireAuth = a.flag("--auth") || config.options.require_auth
// Optional AUTH advertises the challenge without gating commands on it.
// Mandatory AUTH already sends the challenge, so it wins when both are set.
val optionalAuth = !requireAuth && (a.flag("--optional-auth") || config.options.optional_auth)
// Verify is on by default; only disable when the operator explicitly
// opts out (CLI `--no-verify` or `[options].verify_signatures = false`
// in the config).
@@ -109,7 +116,7 @@ fun main(args: Array<String>) {
val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl)
val policyBuilder: () -> IRelayPolicy = {
composePolicy(config, advertisedUrl, requireAuth, verifySigs, parallelVerify)
composePolicy(config, advertisedUrl, requireAuth, optionalAuth, verifySigs, parallelVerify)
}
// Static [authorization] feeds the runtime BanStore only when no
@@ -182,8 +189,9 @@ fun main(args: Array<String>) {
*/
private fun composePolicy(
config: StaticConfig,
advertisedUrl: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
advertisedUrl: NormalizedRelayUrl,
requireAuth: Boolean,
optionalAuth: Boolean,
verifySigs: Boolean,
parallelVerify: Boolean,
): IRelayPolicy {
@@ -191,6 +199,8 @@ private fun composePolicy(
if (requireAuth) {
pieces += FullAuthPolicy(advertisedUrl)
} else if (optionalAuth) {
pieces += OptionalAuthPolicy(advertisedUrl)
}
config.options.reject_future_seconds?.let { secs ->
@@ -107,6 +107,13 @@ data class StaticConfig(
data class OptionsSection(
val reject_future_seconds: Int? = null,
val require_auth: Boolean = false,
/**
* Advertise NIP-42 AUTH without requiring it: the relay sends the
* challenge and records clients that authenticate, but EVENT/REQ/COUNT
* still work for clients that never do. Ignored when [require_auth] is
* true (mandatory AUTH already sends the challenge).
*/
val optional_auth: Boolean = false,
/**
* Defaults to `true`: any relay accepting real traffic should
* verify Schnorr signatures, and verifying-by-default closes
@@ -0,0 +1,57 @@
/*
* 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.nip01Core.relay.server.policies
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Runs the full NIP-42 challenge/verify handshake but never *requires* it.
*
* Like [FullAuthPolicy], [onConnect] emits the AUTH challenge and [accept]
* (AuthCmd) validates a returned event (expiration, freshness, challenge match,
* relay match), recording verified pubkeys into the engine-owned connection
* scope. Unlike [FullAuthPolicy], EVENT, REQ, and COUNT are **always accepted**
* a client that ignores the challenge and never authenticates keeps working.
*
* Use this when you want the relay to *advertise* authentication (so clients that
* do support NIP-42 can identify themselves, and downstream policies can gate or
* rewrite on the caller's identity via [authenticatedUsers]) without locking out
* clients that don't. It is the middle ground between [EmptyPolicy] (no challenge
* at all) and [FullAuthPolicy] (challenge required for every command).
*
* Because it subclasses [FullAuthPolicy], the [authorize] hook and the
* per-connection [authenticatedUsers] set behave identically; only the gating on
* EVENT/REQ/COUNT is relaxed. Subclasses can still tighten specific commands
* (e.g. require auth for kind 4 only) by overriding the relevant [accept] and
* reading [authenticatedUsers].
*/
open class OptionalAuthPolicy(
relay: NormalizedRelayUrl,
) : FullAuthPolicy(relay) {
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> = PolicyResult.Accepted(cmd)
override fun accept(cmd: ReqCmd): PolicyResult<ReqCmd> = PolicyResult.Accepted(cmd)
override fun accept(cmd: CountCmd): PolicyResult<CountCmd> = PolicyResult.Accepted(cmd)
}
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.OptionalAuthPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
@@ -464,6 +465,100 @@ class NostrServerAuthTest {
server.close()
}
// -- NIP-42: optional AUTH -------------------------------------------------
@Test
fun optionalAuthSendsChallengeOnConnect() =
runTest {
val dispatcher = UnconfinedTestDispatcher(testScheduler)
val server = createServer(dispatcher = dispatcher, policyBuilder = { OptionalAuthPolicy(relayUrl) })
val collector = MessageCollector()
server.connect(collector.sendCallback)
assertEquals(1, collector.messages.size)
assertTrue(collector.messages[0].contains("\"AUTH\""))
server.close()
}
@Test
fun optionalAuthAllowsCommandsWithoutAuth() =
runTest {
val dispatcher = UnconfinedTestDispatcher(testScheduler)
val server = createServer(dispatcher = dispatcher, policyBuilder = { OptionalAuthPolicy(relayUrl) })
val collector = MessageCollector()
val session = server.connect(collector.sendCallback)
// The challenge was sent, but a client that ignores it can still write.
val event = testEvent()
session.receive("""["EVENT",${event.toJson()}]""")
val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains(",true,"))
assertFalse((session.policy as OptionalAuthPolicy).isAuthenticated())
// REQ works too — no CLOSED with auth-required.
session.receive("""["REQ","sub1",{"kinds":[1]}]""")
assertTrue(collector.rawMessagesContaining("EOSE").isNotEmpty())
assertTrue(collector.rawMessagesContaining("CLOSED").isEmpty())
server.close()
}
@Test
fun optionalAuthStillRecordsAuthenticatedUsers() =
runTest {
val dispatcher = UnconfinedTestDispatcher(testScheduler)
val server = createServer(dispatcher = dispatcher, policyBuilder = { OptionalAuthPolicy(relayUrl) })
val collector = MessageCollector()
val session = server.connect(collector.sendCallback)
val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage
// A client that DOES authenticate is still verified and recorded, so
// downstream policies can gate on identity.
session.receive(authJson(authEvent(challenge = msg.challenge)))
val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains(",true,"))
assertTrue((session.policy as OptionalAuthPolicy).isAuthenticated())
assertTrue(session.requestContext.authenticatedUsers.contains(pubkey))
server.close()
}
@Test
fun optionalAuthRejectsInvalidAuthButKeepsWorking() =
runTest {
val dispatcher = UnconfinedTestDispatcher(testScheduler)
val server = createServer(dispatcher = dispatcher, policyBuilder = { OptionalAuthPolicy(relayUrl) })
val collector = MessageCollector()
val session = server.connect(collector.sendCallback)
// A bogus AUTH is rejected (challenge mismatch) but the connection is
// not authenticated and commands still flow.
session.receive(authJson(authEvent(challenge = "wrong-challenge")))
val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains(",false,"))
assertTrue(okMessages[0].contains("challenge"))
assertFalse((session.policy as OptionalAuthPolicy).isAuthenticated())
val event = testEvent()
session.receive("""["EVENT",${event.toJson()}]""")
val allOk = collector.rawMessagesContaining("OK")
assertEquals(2, allOk.size)
assertTrue(allOk[1].contains(",true,"))
server.close()
}
// -- Custom AuthPolicy tests -----------------------------------------------
@Test