mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-30 07:37:38 +00:00
Each finding was reproduced by a failing test (or a harness check) first. Shared code (app + amy): - ThreadLevelCalculator: reply cycles (articles `a`-tagging each other or themselves) overflowed the stack in thread ordering and depth. - UrlDetector: a run of '@' recursed once per character (StackOverflowError that escaped the renderer); every ':' rescanned the token for '@', which made 160 KB of `a:` take 27 s (now 0.3 s). - ContentWarningTag.parse crashed on an empty tag, which reduced every kind to the minimal rendering. - EventRendererRegistry.render catches Throwable, so hostile content can't kill a feed. Renderer: kind:1 replies rooted by an `a` tag get reply_to/root; zap amounts of zero or past 21M BTC are null instead of 0 or a wrapped value; kind:3 no longer repeats every follow as a mention. One-shot loaders: - Deletions are applied by address too (a deleted article version came back from a stale relay), and to notifications and `notes show` relay results. - `notifications --type` asks relays for those kinds only; it under-filled. - An naddr of a replaceable kind no longer filters on `#d: [""]`. - Each thread filter reads the store once per load, not on every pass. - A relay list is fetched once per run; large author/id filters are chunked. - seen_on lists only relays that served the event; every relay is hinted. amy: - NIP-29 group content found in the store has no host relay, so routing fell back to public relays. Reactions/replies now need the host (--relay HOST), reposts and quotes of group content are refused, and deletions go to the host only. - The target author's relay list is loaded before the event is built, so a store hit writes the relay hints the app would. - delete/react no longer fan out to every follow of a list event. - A private or broadcast relay list that fails to decrypt is reported. notes-headless.sh adds hint-on-store-hit and four group-routing checks (26/26). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmDZGXB7n5dLuegMARB1Rh
340 lines
15 KiB
Bash
Executable File
340 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# notes-headless.sh — the core social loop between two amy accounts over the
|
|
# embedded `amy serve` relay (geode). No external relay, no other client.
|
|
#
|
|
# Alice and Bob live in separate $HOMEs (separate event stores), so every read
|
|
# Bob does of Alice's events really crosses the relay. We assert that:
|
|
#
|
|
# 1. `notes post` tags an inline #hashtag (`t`), and `notes feed --hashtag`
|
|
# finds the note.
|
|
# 2. `notes show` renders Alice's note for Bob (fetched from the relay):
|
|
# author, hashtag body span, seen_on.
|
|
# 3. `notes reply` emits a NIP-10 kind:1 (root marker + author p-tag);
|
|
# `notes react` a kind:7 `+`; `notes repost` a kind:6;
|
|
# `notes quote` a kind:1 with a `q` tag and a nostr:nevent in the text.
|
|
# 4. `notes thread` from Alice's nested reply lays out root → reply → nested
|
|
# at depths 0/1/2, focus marked.
|
|
# 5. `notifications` shows Alice the reply, reaction, repost and the quote
|
|
# (as a mention); `--type reaction` narrows to one.
|
|
# 6. `delete` refuses someone else's note (exit 1, forbidden) and deletes
|
|
# your own: the relay then no longer serves it (`notes show` → not_found).
|
|
#
|
|
# Then the outbox model across two relays. amy (like the app) drops private and
|
|
# loopback relays from other people's NIP-65 lists, so routing can only be
|
|
# exercised on a relay whose address is not private. Relays A and B run on such
|
|
# an address (auto-detected from the interfaces, or --routing-host); Alice
|
|
# reads and writes on A, Bob on B, Carol reads on A and writes on B. We assert:
|
|
#
|
|
# 7. Bob's reply, reaction and repost of Alice's note reach her inbox (A) and
|
|
# his outbox (B); a post mentioning Carol reaches her inbox (A).
|
|
# 8. Bob's deletion reaches both relays (where the reply was routed).
|
|
# 9. A NIP-25 reaction to Bob's reply (root `e` first, target `e` last) is
|
|
# Bob's notification; a reply to a note from the store carries the
|
|
# author's outbox as its `e` hint; NIP-29 group content (an `h`-tagged
|
|
# kind:9) is reacted to on its host only, can't be reposted, and from the
|
|
# store needs --relay HOST.
|
|
# 10. A reply deleted while relay A was down — A still serves it — stays out
|
|
# of `notes thread`.
|
|
#
|
|
# Without a usable address the section is skipped. Any host can provide one on
|
|
# loopback: `sudo ip addr add 192.0.2.2/32 dev lo` (Linux) or
|
|
# `sudo ifconfig lo0 alias 192.0.2.2 up` (macOS), then --routing-host 192.0.2.2.
|
|
#
|
|
# Usage: ./notes-headless.sh [--no-build] [--port N] [--routing-host IP | --no-routing]
|
|
#
|
|
set -uo pipefail
|
|
|
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
|
|
STATE_DIR="$SCRIPT_DIR/state-notes-headless"
|
|
LOG_DIR="$STATE_DIR/logs"
|
|
|
|
RUN_TS="$(date +%Y%m%d-%H%M%S)"
|
|
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
|
|
RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
|
|
|
|
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
|
|
NO_BUILD=0
|
|
RELAY_HOST=127.0.0.2
|
|
RELAY_PORT=7787
|
|
ROUTING_HOST=""
|
|
ROUTING=1
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--no-build) NO_BUILD=1 ;;
|
|
--port) RELAY_PORT="$2"; shift ;;
|
|
--routing-host) ROUTING_HOST="$2"; shift ;;
|
|
--no-routing) ROUTING=0 ;;
|
|
-h|--help)
|
|
sed -n '3,44p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'
|
|
exit 0 ;;
|
|
*) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
|
|
RELAY_DATA="$STATE_DIR/relay"
|
|
|
|
rm -rf "$STATE_DIR"
|
|
mkdir -p "$STATE_DIR" "$LOG_DIR" "$STATE_DIR/alice" "$STATE_DIR/bob"
|
|
: >"$LOG_FILE"
|
|
: >"$RESULTS_FILE"
|
|
|
|
# shellcheck source=../lib.sh
|
|
source "$SCRIPT_DIR/../lib.sh"
|
|
# shellcheck source=../headless/helpers.sh
|
|
source "$SCRIPT_DIR/../headless/helpers.sh"
|
|
|
|
command -v jq >/dev/null || { fail_msg "jq is required"; exit 1; }
|
|
|
|
if [[ $NO_BUILD -eq 0 ]]; then
|
|
step "building amy (installDist)"
|
|
(cd "$REPO_ROOT" && ./gradlew -q :cli:installDist) >>"$LOG_FILE" 2>&1 \
|
|
|| { fail_msg "gradle :cli:installDist failed"; exit 1; }
|
|
fi
|
|
[[ -x "$AMY_BIN" ]] || { fail_msg "amy binary missing at $AMY_BIN"; exit 1; }
|
|
|
|
# --- the routing relays (A and B) --------------------------------------------
|
|
ROUTE_DIR="$STATE_DIR/routing"
|
|
ROUTE_PORT_A=$((RELAY_PORT + 1))
|
|
ROUTE_PORT_B=$((RELAY_PORT + 2))
|
|
|
|
# An IPv4 amy routes to: not loopback, RFC 1918 or link-local — the ranges
|
|
# RelayUrlNormalizer.isLocalHost drops from other people's relay lists.
|
|
is_routable_ipv4() {
|
|
[[ $1 =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1
|
|
case "$1" in 0.*|10.*|127.*|169.254.*|192.168.*) return 1 ;; esac
|
|
[[ ! $1 =~ ^172\.(1[6-9]|2[0-9]|3[01])\. ]]
|
|
}
|
|
|
|
# The first routable IPv4 on any interface.
|
|
detect_routing_host() {
|
|
local addr
|
|
for addr in $( { hostname -I 2>/dev/null; ip -4 -o addr show 2>/dev/null | awk '{print $4}'; ifconfig 2>/dev/null | awk '/inet /{print $2}'; } | tr ' ' '\n' | sed 's#/.*##'); do
|
|
is_routable_ipv4 "$addr" && { printf '%s\n' "$addr"; return 0; }
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# start_routing_relay NAME PORT — `amy serve` with a persistent --db, so it can be
|
|
# stopped and restarted with its events. Returns 1 when it does not come up.
|
|
start_routing_relay() {
|
|
local name="$1" port="$2" dir="$ROUTE_DIR/relay-$1"
|
|
mkdir -p "$dir"
|
|
if [[ ! -d "$dir/.amy" ]]; then
|
|
HOME="$dir" "$AMY_BIN" --account relay --secret-backend plaintext --json init >>"$LOG_FILE" 2>&1 || return 1
|
|
fi
|
|
if (exec 3<>"/dev/tcp/$ROUTING_HOST/$port") 2>/dev/null; then
|
|
fail_msg "port $port already in use on $ROUTING_HOST"
|
|
return 1
|
|
fi
|
|
nohup env HOME="$dir" "$AMY_BIN" --account relay --secret-backend plaintext \
|
|
serve --host "$ROUTING_HOST" --port "$port" --db "$dir/relay.db" >>"$dir/relay.log" 2>&1 &
|
|
echo "$!" >"$dir/pid"
|
|
local deadline=$(( $(date +%s) + 60 ))
|
|
while [[ $(date +%s) -lt $deadline ]]; do
|
|
curl -sSf -m 1 -H 'Accept: application/nostr+json' "http://$ROUTING_HOST:$port/" >/dev/null 2>&1 && return 0
|
|
kill -0 "$(cat "$dir/pid")" 2>/dev/null || break
|
|
sleep 0.5
|
|
done
|
|
return 1
|
|
}
|
|
|
|
stop_routing_relay() {
|
|
local pid_file="$ROUTE_DIR/relay-$1/pid" pid
|
|
[[ -f "$pid_file" ]] || return 0
|
|
pid="$(cat "$pid_file")"
|
|
if kill -0 "$pid" 2>/dev/null; then
|
|
kill "$pid" 2>/dev/null
|
|
for _ in $(seq 20); do kill -0 "$pid" 2>/dev/null || break; sleep 0.25; done
|
|
kill -9 "$pid" 2>/dev/null || true
|
|
fi
|
|
rm -f "$pid_file"
|
|
}
|
|
|
|
cleanup() {
|
|
stop_routing_relay a
|
|
stop_routing_relay b
|
|
stop_local_relay
|
|
}
|
|
|
|
trap cleanup EXIT
|
|
start_local_relay
|
|
|
|
# Boolean flags go AFTER positionals: amy's parser reads `--flag VALUE`.
|
|
alice() { HOME="$STATE_DIR/alice" "$AMY_BIN" --account alice --secret-backend plaintext --json "$@" 2>>"$LOG_FILE"; }
|
|
bob() { HOME="$STATE_DIR/bob" "$AMY_BIN" --account bob --secret-backend plaintext --json "$@" 2>>"$LOG_FILE"; }
|
|
|
|
check() {
|
|
# usage: check TEST_ID JSON JQ_EXPR [NOTE]
|
|
local id="$1" json="$2" expr="$3" note="${4:-}"
|
|
if jq -e "$expr" <<<"$json" >/dev/null 2>&1; then
|
|
record_result "$id" pass "$note"
|
|
else
|
|
record_result "$id" fail "$(head -c 300 <<<"$json")"
|
|
fi
|
|
}
|
|
|
|
banner "two accounts on $RELAY_URL"
|
|
for who in alice bob; do
|
|
"$who" init >>"$LOG_FILE" || { record_result "$who-init" fail; exit 1; }
|
|
"$who" relay nip65 clear >>"$LOG_FILE"
|
|
"$who" relay add "$RELAY_URL" >>"$LOG_FILE"
|
|
"$who" relay publish-lists >>"$LOG_FILE"
|
|
done
|
|
ALICE_HEX="$(alice whoami | jq -r .hex)"
|
|
BOB_HEX="$(bob whoami | jq -r .hex)"
|
|
record_result "setup" pass "alice=${ALICE_HEX:0:8} bob=${BOB_HEX:0:8}"
|
|
|
|
step "alice posts"
|
|
POST="$(alice notes post "hello #AmyTest from alice")"
|
|
PID="$(jq -r .event_id <<<"$POST")"
|
|
check post "$POST" '.kind == 1 and (.published_to | length) == 1'
|
|
|
|
step "bob shows alice's note (from the relay)"
|
|
SHOW="$(bob notes show "$PID")"
|
|
check show "$SHOW" \
|
|
".source == \"relays\" and .note.author.pubkey == \"$ALICE_HEX\" and (.note.hashtags | index(\"amytest\")) and (.note.body | map(.type) | index(\"hashtag\"))"
|
|
|
|
step "bob reads the hashtag feed"
|
|
FEED="$(bob notes feed --hashtag amytest)"
|
|
check feed-hashtag "$FEED" ".mode == \"hashtag\" and (.notes | map(.event_id) | index(\"$PID\"))"
|
|
|
|
step "bob replies, reacts, reposts, quotes"
|
|
REPLY="$(bob notes reply "$PID" "hi alice")"
|
|
RID="$(jq -r .event_id <<<"$REPLY")"
|
|
check reply "$REPLY" \
|
|
".kind == 1 and (.tags | any(.[0] == \"e\" and .[1] == \"$PID\" and .[3] == \"root\")) and (.tags | any(.[0] == \"p\" and .[1] == \"$ALICE_HEX\"))"
|
|
check react "$(bob notes react "$PID")" '.kind == 7 and .content == "+"'
|
|
check repost "$(bob notes repost "$PID")" ".kind == 6 and .target.event_id == \"$PID\""
|
|
check quote "$(bob notes quote "$PID" "look at this")" \
|
|
".kind == 1 and (.content | contains(\"nostr:nevent1\")) and (.tags | any(.[0] == \"q\" and .[1] == \"$PID\"))"
|
|
|
|
step "alice answers bob; bob reads the thread from the leaf"
|
|
NESTED="$(alice notes reply "$RID" "thanks bob")"
|
|
NID="$(jq -r .event_id <<<"$NESTED")"
|
|
THREAD="$(bob notes thread "$NID")"
|
|
check thread "$THREAD" \
|
|
".root_id == \"$PID\" and ([.notes[].event_id] == [\"$PID\", \"$RID\", \"$NID\"]) and ([.notes[].depth] == [0, 1, 2]) and .notes[2].is_focus"
|
|
|
|
step "alice's notifications"
|
|
NOTIF="$(alice notifications)"
|
|
check notifications "$NOTIF" \
|
|
'[.notifications[].type] | (index("reply") and index("reaction") and index("repost") and index("mention"))'
|
|
check notifications-type "$(alice notifications --type reaction)" \
|
|
".count == 1 and .notifications[0].type == \"reaction\" and .notifications[0].from.pubkey == \"$BOB_HEX\""
|
|
|
|
step "delete"
|
|
bob delete "$PID" >>"$LOG_FILE"
|
|
RC=$?
|
|
[[ $RC -eq 1 ]] && record_result delete-forbidden pass || record_result delete-forbidden fail "exit=$RC"
|
|
check delete "$(alice delete "$PID")" ".deleted == [\"$PID\"] and (.deletions[0].published_to | length) == 1"
|
|
bob notes show "$PID" --refresh >>"$LOG_FILE"
|
|
RC=$?
|
|
[[ $RC -eq 1 ]] && record_result delete-honoured pass "relay no longer serves it" || record_result delete-honoured fail "exit=$RC"
|
|
|
|
# --- routing across two relays ---------------------------------------------
|
|
|
|
routing_skip() {
|
|
record_result routing skip "$1"
|
|
warn "routing section skipped: $1"
|
|
}
|
|
|
|
if [[ $ROUTING -eq 0 ]]; then
|
|
routing_skip "--no-routing"
|
|
elif [[ -z "$ROUTING_HOST" ]] && ! ROUTING_HOST="$(detect_routing_host)"; then
|
|
routing_skip "no non-private IPv4 on any interface; see --help for a loopback alias"
|
|
elif ! is_routable_ipv4 "$ROUTING_HOST"; then
|
|
routing_skip "$ROUTING_HOST is loopback/private: amy drops such relays from others' lists"
|
|
elif ! start_routing_relay a "$ROUTE_PORT_A" || ! start_routing_relay b "$ROUTE_PORT_B"; then
|
|
routing_skip "could not start relays on $ROUTING_HOST (see $ROUTE_DIR/relay-*/relay.log)"
|
|
else
|
|
RA="ws://$ROUTING_HOST:$ROUTE_PORT_A"
|
|
RB="ws://$ROUTING_HOST:$ROUTE_PORT_B"
|
|
banner "routing: relay A $RA, relay B $RB"
|
|
|
|
# Fresh accounts: the loopback ones above know the loopback relay.
|
|
ra() { local who="$1"; shift; HOME="$ROUTE_DIR/$who" "$AMY_BIN" --account "$who" --secret-backend plaintext --json "$@" 2>>"$LOG_FILE"; }
|
|
# reaches A B: a jq test that a publish result went to both relays.
|
|
reaches_both="(.published_to | any(startswith(\"$RA\"))) and (.published_to | any(startswith(\"$RB\")))"
|
|
|
|
for who in alice bob carol; do
|
|
mkdir -p "$ROUTE_DIR/$who"
|
|
ra "$who" init >>"$LOG_FILE" || { record_result "routing-init" fail "$who"; break; }
|
|
ra "$who" relay nip65 clear >>"$LOG_FILE"
|
|
ra "$who" relay dm clear >>"$LOG_FILE"
|
|
ra "$who" relay key-package clear >>"$LOG_FILE"
|
|
done
|
|
ra alice relay outbox add "$RA" >>"$LOG_FILE"; ra alice relay inbox add "$RA" >>"$LOG_FILE"
|
|
ra bob relay outbox add "$RB" >>"$LOG_FILE"; ra bob relay inbox add "$RB" >>"$LOG_FILE"
|
|
ra carol relay outbox add "$RB" >>"$LOG_FILE"; ra carol relay inbox add "$RA" >>"$LOG_FILE"
|
|
for who in alice bob carol; do ra "$who" relay publish-lists >>"$LOG_FILE"; done
|
|
R_ALICE="$(ra alice whoami | jq -r .hex)"
|
|
R_BOB="$(ra bob whoami | jq -r .hex)"
|
|
R_CAROL_NPUB="$(ra carol whoami | jq -r .npub)"
|
|
|
|
step "bob interacts with alice's note across relays"
|
|
R_POST="$(ra alice notes post "hello from relay A" | jq -r .event_id)"
|
|
R_NEVENT="$(ra alice encode nevent "$R_POST" --relay "$RA" | jq -r .nevent)"
|
|
R_REPLY="$(ra bob notes reply "$R_NEVENT" "hi alice, from B")"
|
|
R_RID="$(jq -r .event_id <<<"$R_REPLY")"
|
|
check route-reply "$R_REPLY" "$reaches_both" "alice's inbox + bob's outbox"
|
|
check route-react "$(ra bob notes react "$R_POST")" "$reaches_both"
|
|
check route-repost "$(ra bob notes repost "$R_POST")" "$reaches_both"
|
|
check route-mention "$(ra bob notes post "hey nostr:$R_CAROL_NPUB")" "$reaches_both" "carol's inbox + bob's outbox"
|
|
|
|
step "bob deletes a reply on both relays"
|
|
R_GONE="$(ra bob notes reply "$R_NEVENT" "short-lived" | jq -r .event_id)"
|
|
check route-delete "$(ra bob delete "$R_GONE")" "[.deletions[] | $reaches_both] | all"
|
|
|
|
step "carol reacts to bob's reply (NIP-25 shape)"
|
|
ra carol event --kind 7 --content + \
|
|
--tags "[[\"e\",\"$R_POST\"],[\"e\",\"$R_RID\"],[\"p\",\"$R_ALICE\"],[\"p\",\"$R_BOB\"]]" --relay "$RB" >>"$LOG_FILE"
|
|
check route-reaction-notification "$(ra bob notifications --type reaction)" \
|
|
"[.notifications[].reply_to.event_id] | index(\"$R_RID\")"
|
|
|
|
step "a store hit still writes the author's relay hint"
|
|
check route-hint-cache-hit "$(ra bob notes reply "$R_POST" "from the store")" \
|
|
"(.tags | any(.[0] == \"e\" and .[1] == \"$R_POST\" and ((.[2] // \"\") | startswith(\"$RA\"))))" \
|
|
"e tag hints alice's outbox"
|
|
|
|
step "NIP-29 group content stays on its host"
|
|
R_GROUP="$(ra alice event --kind 9 --content "group only" --tags '[["h","amygroup"]]' --relay "$RA" | jq -r .event.id)"
|
|
R_GROUP_NEVENT="$(ra alice encode nevent "$R_GROUP" --relay "$RA" | jq -r .nevent)"
|
|
check route-group-react "$(ra bob notes react "$R_GROUP_NEVENT")" \
|
|
"(.published_to | length) > 0 and (.published_to | all(startswith(\"$RA\")))" "only the host (A)"
|
|
ra bob notes repost "$R_GROUP_NEVENT" >>"$LOG_FILE"
|
|
RC=$?
|
|
[[ $RC -eq 2 ]] && record_result route-group-repost-refused pass || record_result route-group-repost-refused fail "exit=$RC"
|
|
# Now a store hit: no relay says where the group lives.
|
|
ra bob notes react "$R_GROUP" --content "🔥" >>"$LOG_FILE"
|
|
RC=$?
|
|
[[ $RC -eq 2 ]] && record_result route-group-host-required pass "unknown host refused" || record_result route-group-host-required fail "exit=$RC"
|
|
check route-group-host-flag "$(ra bob notes react "$R_GROUP" --content "🔥" --relay "$RA")" \
|
|
"(.published_to | length) > 0 and (.published_to | all(startswith(\"$RA\")))" "--relay names the host"
|
|
|
|
step "a reply deleted while relay A was down stays out of the thread"
|
|
R_STALE="$(ra bob notes reply "$R_NEVENT" "to be deleted" | jq -r .event_id)"
|
|
stop_routing_relay a
|
|
ra bob delete "$R_STALE" --timeout 3 >>"$LOG_FILE"
|
|
if start_routing_relay a "$ROUTE_PORT_A"; then
|
|
# Precondition: A missed the kind:5 and still serves the reply (Carol asks: her store
|
|
# has neither), or the check below proves nothing.
|
|
if ra carol notes show "$R_STALE" --refresh | jq -e ".seen_on | any(startswith(\"$RA\"))" >/dev/null; then
|
|
check route-thread-deleted "$(ra bob notes thread "$R_POST")" \
|
|
".root_found and ([.notes[].event_id] | index(\"$R_RID\")) and ([.notes[].event_id] | index(\"$R_STALE\") | not)" \
|
|
"relay A still serves it"
|
|
else
|
|
record_result route-thread-deleted fail "precondition: relay A no longer serves the deleted reply"
|
|
fi
|
|
else
|
|
record_result route-thread-deleted fail "relay A did not restart"
|
|
fi
|
|
fi
|
|
|
|
print_summary
|
|
! grep -q $'\tfail\t' "$RESULTS_FILE"
|