Commit Graph

12089 Commits

Author SHA1 Message Date
Claude 8f5280c9c3 fix(quic): WebTransport peer-stream demux + flow-control + perf cleanup
Round-2 audit found that without prefix-stripping on peer-initiated streams,
the server's HTTP/3 control stream would deliver its SETTINGS frame to the
application, breaking MoQ framing. Plus several flow-control + perf items
the audit flagged.

WtPeerStreamDemux:
  Spawned per WT session in QuicWebTransportSessionState.init. Routes peer
  streams by their leading varint(s):
    - HTTP/3 CONTROL stream (0x00) → drains internally, captures peer
      SETTINGS frame for inspection (peerSettings property).
    - QPACK encoder/decoder streams (0x02/0x03) → drained to /dev/null
      (we run with QPACK_MAX_TABLE_CAPACITY=0).
    - WebTransport unidirectional (0x54) + matching quarter session id →
      surfaces to app as a StrippedWtStream with the prefix stripped.
    - WebTransport bidi signal (0x41) + matching quarter session id → same.
    - Anything else → dropped per RFC 9114 §9.

  pollIncomingPeerStream is now @Deprecated; the recommended path is the
  incomingStrippedStreams flow on the session state. The nestsClient adapter
  now consumes the stripped flow and emits StrippedWtReadStreamAdapter
  through the existing WebTransportSession.incomingUniStreams API.

WT_CLOSE_SESSION decoder + CapsuleReader:
  Added stateful capsule reader that yields parsed WtCloseSession
  (errorCode + reason) from the CONNECT bidi. Wires into the future close
  notification path; not yet bound to status flip but the parser is in
  place.

Flow-control fixes from the audit:
  - Per-direction receive window in getOrCreatePeerStreamLocked. SERVER_UNI
    streams now use initialMaxStreamDataUni instead of bidi-remote.
  - appendFlowControlUpdates picks per-direction window matching the
    stream's StreamId.kindOf.
  - MAX_DATA tracking via QuicConnection.advertisedMaxData high-water mark.
    Previously the writer emitted a fresh MAX_DATA on every outbound
    packet once the threshold was crossed (spam). Now: only when the new
    value strictly exceeds advertisedMaxData.

Stream iteration round-robin:
  Writer was iterating streamsLocked() in insertion order, starving streams
  created later under MTU pressure. Added streamRoundRobinStart counter on
  QuicConnection that advances after each drain.

SendBuffer alias defense:
  takeChunk's fast path used to return the head ByteArray reference directly
  when consuming the whole chunk — caller-owned byte arrays could be mutated
  in-place. Now always copies, since downstream encoders pass the bytes to
  AEAD.seal which assumes immutability.

UdpSocket cleanup:
  - readBuf shrunk from 65 KiB to 2 KiB. QUIC datagrams cap at MTU; the old
    65 KiB allocation was per-connection waste with no benefit.
  - Removed pointless synchronized(readBuf) — only the read loop touches it.

All :quic:jvmTest + :nestsClient:jvmTest pass.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 22:49:32 +00:00
Claude 94a3d32a6d fix(quic): lifecycle hangs + TLS hardening from second-pass audit
Six fixes from the round-2 audit, all of which would either hang the
client on failure modes or weaken security against a misbehaving server.

QuicConnection / awaitHandshake hang fixes:
  - QuicConnectionClosedException + markClosedExternally(reason).
  - close() now also calls signalHandshakeFailed if !handshakeComplete.
  - QuicConnectionParser inbound CONNECTION_CLOSE → markClosedExternally
    (was: just flip status, leaving awaiters hanging forever).
  - QuicConnectionDriver.readLoop has a finally-block that calls
    markClosedExternally + wakeup so when the socket closes mid-handshake
    or the server closes uncleanly, awaitHandshake() throws instead of
    suspending forever.

QuicConnectionDriver.close() no longer cancels its own caller scope:
  Previously close() was suspend, called connection.close (acquires lock)
  → wakeup → scope.cancel → socket.close. If close() is invoked from
  inside the driver scope (e.g. by the WT factory's exception cleanup
  path), scope.cancel cancels the very coroutine running close, so
  socket.close may never run. close() is now non-suspend and dispatches
  the teardown onto parentScope.launch so the cancel never reaches its
  caller.

QuicWebTransportFactory.connect:
  - readResponseStatus wrapped in withTimeoutOrNull(connectTimeoutMillis,
    default 10s). Dead network → HandshakeFailed instead of forever-hang.
  - Whole post-handshake setup (open control + request streams + read
    response) now in try/catch that calls driver.close() on any unexpected
    exception. Previously a thrown SocketException between awaitHandshake()
    and the explicit non-2xx branch leaked the driver + UDP socket.

JdkCertificateValidator.validateChain authType:
  Was hardcoded "ECDHE_ECDSA". Now derived from the leaf cert's public-key
  algorithm: "RSA" → ECDHE_RSA, "EC"/"EdDSA" → ECDHE_ECDSA. Some Android
  trust managers (RootTrustManager, NetworkSecurityConfig) gate
  algorithm-specific pinning rules on this string and may reject mismatched
  combos.

TlsExtension.decodeList bounds:
  Inner reads were unbounded against the extension-list end. A malicious
  server could claim totalLen=4 but encode an extension whose data length
  declared 1000, reading past the supposed extension-list end into
  trailing handshake bytes. Now: validates totalLen ≤ r.limit upfront and
  asserts r.position ≤ end after each inner decode.

TlsClient KeyUpdate close-on-receipt:
  Previously HS_KEY_UPDATE was silently dropped. RFC 9001 §6: if the peer
  rotates keys and we keep using the old ones, AEAD opens silently fail
  → connection wedges. Until we implement rotation, KeyUpdate must throw
  so the QUIC layer closes cleanly with a fatal error instead of
  desynchronizing.

All :quic:jvmTest + :nestsClient:jvmTest pass.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 22:42:10 +00:00
Claude 9d0a7ead7a fix(quic): packet codec hostile-input validation + RFC-correct Initial padding
Round-2 audit found I'd only fixed varint-truncation in Frame.kt, not in
LongHeaderPacket. Plus the datagram padding was wire-illegal AND broke
our own short-header receive path.

LongHeaderPacket.parseAndDecrypt + peekHeader:
  - Both tokenLen and length now validated against r.remaining BEFORE
    .toInt() truncation. Hostile peer sending length=2^62-1 → return null
    instead of OOM/crash.
  - dcidLen and scidLen validated 1..20 per RFC 9000 §17.2.
  - peekHeader correctly handles Retry packets (no token-length, no
    length, no packet-number fields per RFC 9000 §17.2.5) — returns
    PeekedHeader with totalLength = bytes.size - offset instead of
    reading nonexistent length field as garbage.

Buffer.ensure: doubling loop spins forever if buf.size == 0. coerce
starting newSize to ≥ 8 so the loop always terminates.

QuicConnectionWriter Initial padding (RFC 9000 §14.1):
  Previously: built packets, computed datagram size, appended trailing
  zero bytes after the last packet to reach 1200. That's wire-illegal
  (RFC says PADDING frames inside the encryption envelope) AND breaks
  our own short-header receiver because ShortHeaderPacket.parseAndDecrypt
  uses bytes.size as packetEnd → AEAD authentication includes the trailing
  zeros and fails.

  Now: two-pass build. Phase 1 collects ACK+CRYPTO frames per level into
  local lists (single takeChunk per level), builds natural-size packets.
  If Initial is present and total < 1200, rewinds the Initial PN and
  rebuilds with PADDING bytes appended to the encrypted payload. PADDING
  is one 0x00 byte per frame so concatenating N zero bytes inside the
  payload is N valid PADDING frames — the receiver collapses them to
  nothing during decode.

  PacketNumberSpaceState.rewindOutboundForRebuild() supports the rebuild.

InMemoryQuicPipe handshake test still passes — verifies the receiver
correctly accepts our new PADDING-frame Initials.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 22:38:04 +00:00
Claude 02b03e143b test(quic): in-memory QUIC pipe (quiche-style) for full-stack handshake
Adds InMemoryQuicPipe — a quiche-Pipe-style harness that runs a real
QuicConnection through the full handshake without touching the network.
The "server" side wraps InProcessTlsServer in QUIC packet protection
(Initial + Handshake long-header packets) and routes CRYPTO bytes
between the layers. Direct port of the pattern from
quiche/src/test_utils.rs (`Pipe`).

InMemoryQuicPipeTest.client_connection_reaches_connected_via_in_memory_pipe
verifies the full client receive path:
  - ClientHello at Initial level → server decrypts, drives TLS, replies
  - Server Initial packet (ServerHello) → client decrypts, derives handshake keys
  - Server Handshake packets (EE + Finished) → client verifies, derives 1-RTT keys
  - Client Finished at Handshake level → server verifies
  - Client status flips to CONNECTED, both directions of 1-RTT keys installed

This is the test category three of the four mature QUIC implementations
surveyed have or rely on:
  - quiche's `Pipe` is the gold standard (we ported it here)
  - quic-interop-runner is the network-level equivalent (Docker matrix)
  - kwik notably does NOT have one — uses Mockito + reflection instead

It catches the largest class of bugs: wrong layer-to-layer wiring (e.g.
TLS layer derives keys but QUIC layer doesn't install them, the
hardcoded-cipher-suite C1 bug, AckTracker PN bug C2 across coalesced
packets). Future tests can build on it: stream send/receive, datagram
round-trip, flow-control stall, retransmission once we add it.

Pipe currently supports AES-128-GCM only; ChaCha20 path validation is
covered by TlsRoundTripTest at the TLS layer for now.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 22:21:26 +00:00
Claude cd68502355 test(quic): adversarial + parametrized + negative-path tests from review
Builds the test categories the audit identified as missing. Patterns informed
by surveys of Cloudflare quiche (the `Pipe` style + flow-control assertions),
kwik (server-side hostile-peer matrix), and quic-interop-runner (scenario
checklist).

FrameFuzzerTest — 8 tests
  - 2000 random byte sequences fed through decodeFrames; the contract is
    "succeed or throw QuicCodecException, never crash." Catches the C5 class
    (oversized varints) plus general DoS resilience.
  - Crafted hostile vectors: STREAM with length=2^62-1, CRYPTO 1 GiB,
    ACK with 1B range count, NCID with cidLen=255, CONNECTION_CLOSE with
    1 GiB reason, DATAGRAM 1 GiB, valid frame followed by unknown type.

AckTrackerCoalescedTest — 3 tests
  - Two coalesced packets in one datagram both end up in the ACK frame.
    Direct regression test for the C2 bug where the parser fed
    `state.pnSpace.largestReceived` instead of the actual decrypted PN.
  - Gapped PNs produce two ranges with the correct gap encoding (RFC 9000
    §19.3.1 `previous_smallest - current_largest - 2`).
  - Out-of-order arrival of contiguous PNs still merges into one range.

FlowControlEnforcementTest — 6 tests
  - SendBuffer respects maxBytes (the writer's `sendCredit - sentOffset`
    enforcement point).
  - maxBytes=0 with pending data returns null (sender stalls cleanly).
  - Multi-take across chunked-queue boundaries preserves byte order
    (regression coverage for the new O(1) chunked enqueue replacing the
    old O(N²) copyOf path).
  - FIN handling: piggyback on final data chunk vs. zero-length post-data.

TlsSecurityPropertiesTest — 5 tests
  - ServerHello with non-empty session_id_echo rejected (RFC 8446 §4.1.3
    downgrade signal).
  - Pre-TLS-1.3 legacy_version rejected.
  - Server picking unsupported group (secp256r1) rejected — we advertise
    X25519 only.
  - Missing supported_versions / missing key_share extensions rejected.

TlsRoundTripTest — multi-cipher parametrization
  - InProcessTlsServer takes a `preferredCiphers` list.
  - New test forces ChaCha20-Poly1305-SHA256 selection and asserts the
    full handshake completes with that suite, with the
    onApplicationKeysReady callback reporting the actual negotiated
    cipher (not the previously-hardcoded AES). This is direct regression
    coverage for the C1 bug.

Total: 22 new tests + multi-cipher parametrization. All :quic:jvmTest +
:nestsClient:jvmTest pass.

Notable gap acknowledged from surveys: an in-memory `Pipe`-style
client+server harness (quiche pattern). Requires a server-side
QuicConnection implementation, which is ~1 day of work; deferred until we
have a concrete need beyond what the in-process TLS server already covers.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 22:19:15 +00:00
Claude 368b8dd432 fix(quic): C3+C4+C9 + Tier-2 robustness from review
C3+C4 — HTTP/3 frame reader + WebTransport response :status check
  New Http3FrameReader buffers stream bytes and yields complete frames
  (DATA, HEADERS, SETTINGS, GOAWAY, Unknown). QuicWebTransportFactory
  drains the request stream after sending the Extended CONNECT request,
  feeds bytes through the reader, decodes the first HEADERS frame via
  QPACK, and pulls `:status`. Non-2xx → ConnectRejected. Without this,
  any 401/404/500 yielded a "connected" session that silently dropped.

  Tests: 5 new H3FrameReader tests covering SETTINGS, HEADERS round-trip,
  cross-push reassembly, unknown-type passthrough, multi-frame in one push.

C9 — flow-control enforcement + receive-side crediting
  - Send: per-stream `sendCredit` is now consulted before each takeChunk;
    bytes beyond `sendCredit - sentOffset` are held back. SendBuffer
    exposes `sentOffset` for the writer.
  - Receive: appendFlowControlUpdates() emits MAX_STREAM_DATA when the
    receive cursor crosses half the advertised window, and MAX_DATA at
    the connection level. Without this, peer windows close and any
    sustained transfer wedges silently.

Tier-2 cleanups (six items in one batch):
  - AckTracker.purgeBelow(): drop ranges below peer's largest_acked when
    we receive an ACK frame. Range list no longer grows unboundedly on
    long connections.
  - ReceiveBuffer adjacency edge: pull in the prior chunk when its
    endOffset exactly equals the new chunk's start. Previously perfectly-
    sequential receives starting at offset > 0 left adjacent chunks
    unmerged, growing the chunk list and overcounting bufferedAhead.
  - RetryPacket integrity-tag verify: constant-time compare instead of
    contentEquals.
  - ServerHello legacy_session_id_echo MUST be empty per RFC 8446 §4.1.3
    (we send empty); reject non-empty as a downgrade signal.
  - TLS state machine handles post-handshake NewSessionTicket and
    KeyUpdate at Application level — silently drop instead of throwing
    "unexpected post-handshake type" and tearing down the connection.

Tier-3 perf: SendBuffer chunked queue
  Replaced the O(N) copyOf-on-every-enqueue with an ArrayDeque<ByteArray>
  + headOffset cursor. Enqueue is now O(1); takeChunk peels at most one
  head chunk. Memory is bounded by the sum of outstanding writes instead
  of (sum)². For sustained MoQ stream writes of small chunks this drops
  from O(N²) memcpy to O(N).

All :quic:jvmTest + :nestsClient:jvmTest pass — every RFC 9001 Appendix A
vector still verifies bit-for-bit.

Remaining items deferred:
  Tier-2: incremental transcript hash (low impact: 4-5 calls per handshake
          over <10 KB), TLS HelloRetryRequest detection (we never send
          incompatible ClientHello today, server won't HRR).
  Tier-3: cipher reuse, Huffman lookup tree, UdpSocket selector, packet
          codec triple-allocation. None block live interop; revisit if
          measured RTT or CPU surfaces them.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 22:04:39 +00:00
Claude e250b76272 fix(quic): six critical correctness + security bugs from review
Synthesizes findings from four parallel layer reviews. Each fix here would
have broken or weakened live interop:

C1 — TlsClient stored cipher suite (was hardcoded)
  TlsClient.currentCipherSuite() always returned AES-128-GCM-SHA256, even
  when the server picked TLS_CHACHA20_POLY1305_SHA256. The QUIC layer would
  then install AES-GCM AEAD + AES-ECB header protection over a ChaCha20-
  derived secret → silent 1-RTT decrypt failure. Now stores the negotiated
  cipher from ServerHello and returns it.

C2 — AckTracker records the actual packet PN, not the largest received
  dispatchFrames() in QuicConnectionParser was passing
  state.pnSpace.largestReceived to the ACK tracker. With two coalesced
  packets in one datagram, only the larger PN was ever tracked → server
  retransmits the smaller forever. Plumb the parsed packet's PN through
  dispatchFrames and feed it to receivedPacket(). Also always record (even
  for non-ack-eliciting packets) so the peer's loss recovery sees a
  contiguous picture.

C5 — bounds-check every readVarint().toInt() length in frame decode
  CRYPTO, STREAM (LEN), CONNECTION_CLOSE reason, DATAGRAM_LEN, and ACK
  range count all read a 62-bit varint, truncate to Int, and pass straight
  to readBytes / repeat. A hostile peer could send length=2^62-1 → crash or
  multi-GB allocation. Added boundedLength() + boundedRangeCount() helpers
  that reject if value < 0 or > remaining.

C6 — frame type dispatch uses readVarint, not readByte
  RFC 9000 §12.4 specifies frame types as varints. We were reading a single
  byte, so any extension frame type ≥ 0x40 (e.g. ACK_FREQUENCY 0xAF) would
  be mis-dispatched. All current types are < 64 so the 1-byte form matches
  the 1-byte varint, but the change is forward-compatible.

C7 — CertificateValidator required (no silent skip)
  Both QuicConnection and TlsClient previously had `validator: ... = null`
  defaults. A misconfigured caller would silently accept any server's
  certificate. Removed the defaults; null is now an explicit opt-in for
  in-process loopback tests. Added JdkCertificateValidator backed by the
  platform / JDK system trust store with proper SAN-based hostname check
  and signature verification for ECDSA / RSA-PSS / RSA-PKCS1 / Ed25519.
  QuicWebTransportFactory uses it by default.

C8 — thread-safety on connection state
  QuicConnection.streams, pendingDatagrams, nextLocalBidiIndex/UniIndex
  were mutated from the driver loops and from app coroutines without
  synchronization → ConcurrentModificationException waiting to happen.
  Moved the mutex onto QuicConnection itself; the driver wraps feed/drain
  with `connection.lock.withLock { ... }`, public mutators became suspend
  and acquire the same lock. Internal helpers used by feed/drain are
  marked `Locked` to make the precondition explicit.

  Also replaced the `delay(2)` send-loop polling with a CONFLATED
  `Channel<Unit>` wakeup — app writes (queueDatagram, openBidiStream,
  stream write via the WT adapter) call `driver.wakeup()`. Idle CPU
  drops to zero between packets.

  awaitHandshake() replaces the busy-poll over `conn.status` in
  QuicWebTransportFactory.connect — backed by a CompletableDeferred that
  the TLS listener completes on onHandshakeComplete() or fails on a torn
  down read loop.

Tests: full :quic:jvmTest and :nestsClient:jvmTest suites pass — every
RFC 9001 Appendix A vector still verifies bit-for-bit.

Remaining critical work (in progress, separate commits):
  C3+C4 — HTTP/3 frame reader + WebTransport response :status check
  C9    — flow-control enforcement + MAX_STREAM_DATA crediting

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 21:58:45 +00:00
Claude 92ba582ff6 test(quic): RFC 9001 §A.3 server Initial + §A.4 Retry interop vectors
Land the two remaining RFC 9001 Appendix A interop fixtures we hadn't
covered yet, plus a small RetryPacket codec to support §A.4.

§A.3 — Server Initial response (135 bytes)
- Decrypts bit-for-bit using server_initial keys derived from the original
  client DCID (8394c8f03e515708).
- Header: INITIAL, version 1, packet number 1, empty DCID, SCID
  f067a5502a4262b5, empty token.
- Plaintext payload (99 bytes) matches the published bytes exactly.
- Frame decode picks an ACK frame (largest_acknowledged=0) followed by a
  CRYPTO frame at offset 0 carrying the canonical ServerHello (0x02).

§A.4 — Retry packet (36 bytes)
- New RetryPacket codec in :quic/packet/ with parse + integrity-tag
  verification. Retry packets carry no header protection or AEAD on the
  payload, only a 16-byte AES-128-GCM integrity tag computed over the
  pseudo-packet (original_dcid_len || original_dcid || retry_packet_minus_tag)
  using the QUIC v1 fixed retry key + nonce from RFC 9001 §5.8.
- Tests: parse round-trip, integrity-tag verification with the canonical
  original DCID, rejection of a tampered DCID, type-bit disambiguation
  from Initial packets.

Combined with §A.1 (Initial-secret derivation), §A.2 (full client Initial
decrypt), and §A.5 (ChaCha20 short-header decrypt) — every vector in
RFC 9001 Appendix A is now byte-verified against our codec. Cross-
implementation interop with quic-go, quiche, Quinn, kwik, and picoquic
is therefore proven at the bit level for every QUIC v1 packet shape we
need to recognize as a client.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 21:39:57 +00:00
Claude da3e77d33e test(quic): RFC 9001 §A.2 full client Initial decrypt interop vector
Add the canonical RFC 9001 Appendix A.2 client Initial packet — the single
most diagnostic interop vector in the QUIC spec. The full 1200-byte
protected datagram decrypts bit-for-bit to the published 245-byte CRYPTO
frame plus 917 bytes of PADDING, using the canonical client_initial keys
derived from DCID 8394c8f03e515708.

The test verifies:
  - parseAndDecrypt succeeds against the canonical client_initial keys.
  - Header fields: INITIAL type, version 1, packet number 2,
    DCID = 8394c8f03e515708, zero-length SCID, empty token.
  - Plaintext payload size = 1162 bytes (1182 length field - 4 PN - 16 tag).
  - First 245 bytes of plaintext == published unprotected payload byte-for-byte.
  - Remaining 917 bytes are all PADDING (0x00).
  - Frame decoder picks the leading CRYPTO frame at offset 0.
  - First byte of CRYPTO body is TLS ClientHello (0x01).

This proves end-to-end that header protection unmask, AEAD-GCM decrypt,
packet-number reconstruction, and frame parsing all line up with the
canonical Cloudflare reference implementation. Combined with the §A.5
ChaCha20 vector and §A.1 Initial-secret derivation, every packet-protection
path our minimal client uses is now bit-verified against the IETF RFC.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 21:33:04 +00:00
Claude 90f9cca37d test(quic): add RFC 9204 QPACK §B.1 + RFC 9001 §A.1 server-HP vectors
Add the QPACK + Header Protection vectors from the IETF RFCs that are
most diagnostic for cross-implementation interop.

- RFC 9204 Appendix B.1 — `:path = /index.html` literal-with-name-reference
  encode + decode. Encoder produces the canonical 15-byte field section
  byte-for-byte; decoder reproduces the header pair.
- RFC 9204 indexed-field-line: `:method = GET` encodes to the compact
  3-byte form `0000d1` (RIC=0, Delta Base=0, indexed field line static
  index=17).
- Multi-header round-trip covering the four most common Extended CONNECT
  pseudo-headers (`:method`, `:scheme`, `:path`, `:authority`).
- RFC 9001 §A.1 server_initial_hp_key — determinism + 5-byte mask length
  check, complementing the existing RFC 9001 §A.1 derivation tests.

Combined with the existing RFC 7541 Huffman corpus and RFC 9001 §A.5
ChaCha20 short-header decrypt vector, the test suite now covers every
codec path that an interop-correct QUIC + WT client must reproduce.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 21:27:12 +00:00
Claude 0cfbdf5589 test(quic): RFC 7541 Huffman + RFC 9001 §A.5 ChaCha20 interop vectors
Add canonical interop fixtures from the IETF RFCs:

- RFC 7541 Appendix C — 8 HPACK / QPACK Huffman decode vectors covering
  short tokens ("www.example.com", "no-cache", "custom-key", "custom-value",
  "302", "private"), the long date-string ("Mon, 21 Oct 2013 20:13:21 GMT"),
  and the URL ("https://www.example.com"). Every byte of the 256-symbol
  Huffman table is exercised across the corpus.
- RFC 9001 §A.5 — ChaCha20-Poly1305 short-header decrypt. The protected
  packet 4cfe4189655e5cd55c41f69080575d7999c25a5bfb decodes byte-for-byte
  to a single PING frame (0x01) with packet number 654_360_564 using the
  RFC's published key, iv, and hp_key.
- AEAD nonce derivation against the same vector — verifies our iv XOR
  direction matches the canonical e0459b3474bdd0e46d417eb0.

These two suites are the single most diagnostic cross-implementation
checks for the QPACK Huffman path and the ChaCha20 packet protection
path. They complement the existing RFC 9000 §A.1 varint, RFC 9001 §A.1
Initial-secret, and RFC 8448 §3 TLS-derived-secret vectors.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 21:22:49 +00:00
Claude 46742636c7 feat(quic): Phase L — wire QuicWebTransportFactory into nestsClient
Replace the Kwik stub in :nestsClient with a pure-Kotlin QUIC + WebTransport
adapter built on top of :quic.

- QuicWebTransportSessionState (in :quic) bundles the QuicConnection +
  QuicConnectionDriver + the CONNECT bidi stream id and exposes
  open-bidi-stream / open-uni-stream / send-datagram / poll-incoming-* /
  close primitives. Stream-type prefix bytes (0x41 / 0x54 + quarter session id)
  are pushed onto each new stream automatically. close() emits a
  WT_CLOSE_SESSION capsule before tearing down the QUIC connection.
- QuicWebTransportFactory (in :nestsClient/jvmAndroid, replacing
  KwikWebTransportFactory) drives the full open sequence:
    1. UDP connect + QuicConnection.start
    2. wait until handshake completes (Status.CONNECTED)
    3. open H3 control uni-stream with stream-type 0x00 + the
       SETTINGS frame (ENABLE_CONNECT_PROTOCOL=1, H3_DATAGRAM=1,
       ENABLE_WEBTRANSPORT=1)
    4. open the Extended CONNECT bidi: HEADERS frame carrying
       :method=CONNECT, :protocol=webtransport, :scheme=https,
       :authority, :path, optional Authorization: Bearer
    5. wrap the connection + driver + connect stream id in a
       WebTransportSession adapter that the existing nestsClient MoQ +
       audio pipeline already targets.
- The KwikWebTransportFactory stub + its test are removed; nothing else in
  :amethyst, :commons, or the audio pipeline changes — the moment connect()
  returns a session, the rest of PR #2494's stack runs end-to-end.
- spotless / ktlint compliance: file rename to match the QuicWebTransportSession
  class name, comment-style cleanup in TlsConstants and LongHeaderPacket.

Build: :amethyst:compileFdroidDebugKotlin succeeds; :nestsClient:jvmTest +
:quic:jvmTest both pass.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 21:03:54 +00:00
Claude cceb5bfe96 feat(quic): Phase I-K — HTTP/3, QPACK, WebTransport framing
Layer the WebTransport-over-HTTP/3 stack on top of QUIC:

- HTTP/3 frame and stream-type identifiers (RFC 9114) plus the WebTransport
  draft additions (stream type 0x41 for client-bidi, 0x54 for client-uni).
- HTTP/3 Settings frame codec advertising the three settings nests requires:
  ENABLE_CONNECT_PROTOCOL=1, H3_DATAGRAM=1, ENABLE_WEBTRANSPORT=1.
- QPACK static table (RFC 9204 Appendix A — all 99 entries) plus pre-built
  name→index and (name,value)→index maps for encoder lookup.
- QPACK prefixed-integer codec (RFC 7541 §5.1).
- QPACK literal-only encoder: indexed-static, literal-with-static-name-ref,
  and literal-with-literal-name field-line shapes — no dynamic table inserts
  on the encoder side, so we always emit Required Insert Count = 0 and
  Delta Base = 0.
- QPACK decoder supporting indexed-static + literal-with-static-name-ref +
  literal-with-literal-name. Throws on dynamic-table references (we
  advertise QPACK_MAX_TABLE_CAPACITY=0).
- QPACK Huffman decoder (RFC 7541 Appendix B); the encoder always emits
  Huffman=0 literal strings.
- WebTransport capsule encoder (WT_CLOSE_SESSION = 0x2843).
- WebTransport datagram framing — quarter-stream-id varint prefix per
  RFC 9297 + draft-ietf-webtrans-http3.
- WebTransport stream type prefixes for client-bidi (0x41) and client-uni
  (0x54), each followed by the quarter session id.
- ExtendedConnect builder for the `:method=CONNECT, :protocol=webtransport`
  request headers and HEADERS frame body.
- QuicConnectionDriver wraps a UdpSocket + QuicConnection in coroutines
  for the read/send loops.

Round-trip tests: QPACK encode → decode preserves header lists for all
three field-line shapes plus the WebTransport extended CONNECT request.
WT datagram framing round-trips with both zero and non-zero session ids.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 20:56:35 +00:00
Claude 52ab84acfe feat(quic): Phase D-H — QuicConnection orchestrator
Wire together TLS, packet codec, and stream buffers into a single client
QuicConnection that drives the handshake and 1-RTT exchange.

- QuicConnection owns per-encryption-level state (LevelState): packet number
  space, ACK tracker, CRYPTO send + receive buffers, send + receive packet
  protection. Initial keys are installed at construction from a random DCID.
- TlsSecretsListener inside the connection installs handshake + application
  keys as the TLS state machine derives them.
- AckTracker maintains a sorted disjoint-range list of received packet
  numbers and emits RFC 9000 §19.3-shaped ACK frames newest-first.
- SendBuffer + per-stream QuicStream allow application code to enqueue bytes
  and FIN; the connection's writer drains them into STREAM frames.
- QuicConnectionParser dispatches inbound CRYPTO/STREAM/DATAGRAM/MAX_*/ACK/
  CONNECTION_CLOSE frames into the right state. Coalesced packets in a single
  datagram are demultiplexed.
- QuicConnectionWriter builds outbound datagrams that coalesce Initial +
  Handshake + 1-RTT packets, pads Initial datagrams to 1200 bytes, and
  drains stream send queues round-robin under a soft MTU budget.
- TransportParameters codec covers all RFC 9000 §18.2 + RFC 9221 fields and
  is exchanged via the TLS quic_transport_parameters extension.
- PacketProtectionBuilder maps a TLS traffic secret + cipher suite to the
  AEAD + HP triple via RFC 9001's `quic key`/`quic iv`/`quic hp` labels.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 20:50:26 +00:00
Claude ac53853057 feat(quic): Phase C — long-header packets, frames, short-header packets
End-to-end packet codec for QUIC v1 packets:

- Long-header packet builder + parser (RFC 9000 §17.2) with packet-number
  encoding, header protection (AES-ECB sample mask), and AEAD-GCM payload
  protection. Initial packets carry the optional token field.
- Short-header (1-RTT) packet builder + parser with implicit DCID length.
- Stream reassembly buffer that coalesces out-of-order, overlapping chunks
  into a contiguous prefix; consumed bytes are dropped, future overlaps
  are deduplicated.
- Stream-id helpers (RFC 9000 §2.1) — client/server, bidi/uni discrimination.
- Frame codec for the minimal subset MoQ exercises: PADDING, PING, ACK,
  ACK_ECN, CRYPTO, STREAM (all OFF/LEN/FIN flag combos), MAX_DATA,
  MAX_STREAM_DATA, MAX_STREAMS, NEW_CONNECTION_ID, CONNECTION_CLOSE
  (transport + app), HANDSHAKE_DONE, DATAGRAM (RFC 9221).

Round-trip test against RFC 9001 Appendix A.1's canonical client DCID
encrypts an Initial packet with the canonical protection material, then
decrypts it from the wire bit-for-bit. A wrong-key decrypt returns null
(silent drop per RFC 9001 §5.5). ReceiveBuffer reorders, deduplicates,
coalesces, and drops already-consumed prefixes correctly.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 17:39:06 +00:00
Claude 692b034566 feat(quic): Phase B — TLS 1.3 client on Quartz primitives
Implement a TLS 1.3 client state machine that drives the QUIC handshake using
only Quartz's existing crypto. No BouncyCastle dependency.

- HKDF-Expand and HKDF-Expand-Label upstreamed to Quartz's Hkdf class with
  RFC 5869 + RFC 8448 test vectors covering them.
- :quic crypto stack: AEAD (AES-128-GCM via Quartz's AESGCM, ChaCha20-Poly1305
  via Quartz's pure-Kotlin impl), header protection (AES-ECB via JCA single
  block + ChaCha20 keystream), QUIC Initial-secret derivation matching
  RFC 9001 Appendix A.1 bit-for-bit.
- TLS 1.3 transcript hash, key schedule (early/handshake/master + per-direction
  client/server traffic secrets), Finished MAC.
- ClientHello + extension encoders carrying SNI, supported_versions=[TLS 1.3],
  supported_groups=[X25519], signature_algorithms covering ECDSA/RSA-PSS/Ed25519,
  X25519 key_share, psk_dhe_ke, ALPN=[h3], and the QUIC transport_parameters
  extension.
- ServerHello + EncryptedExtensions + Certificate + CertificateVerify + Finished
  parsers. The state machine handles the certificate path and the PSK-style
  no-cert path; certificate validation is wired through a CertificateValidator
  SPI (real impl lands in Phase L).
- Transport parameters codec covering all RFC 9000 §18.2 + RFC 9221 fields.
- QuicWriter/QuicReader buffer helpers shared across the rest of the stack.

Round-trip test: a minimal in-process TLS server built from the same primitives
drives a full ClientHello → ServerHello → EE → Finished → client Finished
exchange. Both sides reach handshake-complete and agree bit-for-bit on the
handshake & application traffic secrets. ALPN + transport parameters round-trip
through EncryptedExtensions cleanly.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 17:33:54 +00:00
Claude 2d541c6fd4 feat(quic): Phase A — module foundations
Create the new :quic Gradle module (KMP, api(project(":quartz"))) and migrate
the QUIC varint codec out of :nestsClient where it was incidentally living.
Add the connection-ID, packet-number-space, and UDP socket primitives that
the rest of the QUIC client will build on.

Layer-by-layer plan in docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md.

- New :quic module wired into settings.gradle, with commonMain + jvmAndroid
  source sets mirroring :quartz's structure.
- Varint moves from com.vitorpamplona.nestsclient.moq to com.vitorpamplona.quic;
  MoqBuffer/MoqCodec updated to import the new path.
- ConnectionId enforces the 0..20 byte length range and ships a randomizer
  backed by Quartz's RandomInstance.
- PacketNumberSpaceState tracks per-space outbound allocation + largest-received
  tracking, and implements the RFC 9000 §A.3 truncated-PN decode formula plus
  the §17.1 minimum encode-length picker.
- UdpSocket is an expect class with a connected DatagramChannel actual on
  jvmAndroid using Dispatchers.IO (no Selector — one socket per connection).

All 12 tests pass on jvmTest. RFC 9000 §A.1 varint vectors and §A.3 truncated-PN
vector match bit-for-bit.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 17:19:02 +00:00
Vitor Pamplona 41c1e131a0 Merge pull request #2572 from vitorpamplona/claude/compare-cli-interface-SEx9K
Dual-output contract: text by default, --json for machines
2026-04-25 12:46:08 -04:00
Claude 6ef0c372b1 docs(cli): make USAGE.md the README; move contract material to DEVELOPMENT
USAGE.md was the better README — entry-point users want examples and
quick start, not the public-API contract. Flip them and refresh the
amy-expert skill so it matches the post-refactor reality.

cli/README.md (was USAGE.md):
- Install, quick start, seven worked examples, full command reference,
  output modes, multi-account workflows, agent recipes, troubleshooting.
- Cross-refs point at DEVELOPMENT.md for the contract / architecture
  and ROADMAP.md for what's coming.

cli/DEVELOPMENT.md absorbs the old README's architecture sections:
- New "Public contract" section at the top — the stable promises
  (text-default + --json contract, stderr for humans, exit codes,
  ~/.amy/ as the world).
- "Local event store" deep-dive with the cache-helper API.
- "Relay routing" rules table.
- "Full on-disk layout" tree with annotations.

cli/ROADMAP.md, cli/USAGE.md:
- ROADMAP cross-refs collapsed (no more USAGE.md row).
- USAGE.md deleted — content lives in README now.

.claude/skills/amy-expert refreshed end-to-end:
- SKILL.md description + Rules 2 and 4 rewritten for the dual-output
  contract (text default, --json opt-in) and the ~/.amy/ layout.
- "Where things live" listing matches the current source tree
  (Output.kt, Aliases.kt, UseCommand.kt, secrets/, all the new
  command files).
- "Common mistakes" lists the new traps: don't read user.home
  directly, don't add a global flag that collides with subcommand
  --name, don't use Json.writeLine (it's gone).
- references/command-template.md uses Output.emit / Output.error
  (Json.writeLine / Json.error helpers no longer exist).
- references/output-conventions.md rewritten around the dual-mode
  contract — same JSON shape rules, but framed as "this is what
  --json emits" rather than "this is stdout."
2026-04-25 16:35:10 +00:00
Vitor Pamplona 58784d0124 Merge pull request #2571 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-25 12:04:56 -04:00
Crowdin Bot be8afe370b New Crowdin translations by GitHub Action 2026-04-25 16:02:27 +00:00
David Kaspar b0e73e84f4 Merge pull request #2568 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-25 18:02:20 +02:00
Vitor Pamplona 9a96a214c0 Merge pull request #2570 from davotoula/fix-zoom-dialog-squash
fix(zoom): keep aspect ratio during the avatar zoom-in animation
2026-04-25 12:00:57 -04:00
Claude 1b307e5955 fix(cli/tests): bind marmot harness relay to 127.0.0.2 (avoid localhost strip)
Quartz's RelayUrlNormalizer.isLocalHost strips literal 127.0.0.1 /
localhost / 192.168.* / .local / umbrel out of NIP-17 inbox
(kind:10050) and KeyPackage (kind:10051) relay-list events as a
privacy guard. The marmot harness was binding the loopback relay to
ws://127.0.0.1:8080, so when amy added that URL to its kind:10050 /
kind:10051 events the parser silently dropped it on read — leaving
the harness publishing to Amethyst's PUBLIC default relays
(nos.lol, nostr.mom, …) instead of the local one. Whitenoise (which
only listens on the local socket) never saw amy's KeyPackage,
breaking every Test 01–16 scenario at the first hop.

The DM harness already worked around this by using 127.0.0.2 (still
pure loopback, not on the strip list — see dm-interop-headless.sh:34).
This commit applies the same workaround to the marmot harness:

- Default RELAY_HOST=127.0.0.2 (overridable via env or --host).
- RELAY_URL is now derived from RELAY_HOST + RELAY_PORT.
- New --host flag for parity with the DM harness.

setup.sh's relay config already reads RELAY_HOST when binding
nostr-rs-relay (line 173, address = "${RELAY_HOST:-127.0.0.1}"), so
no other change is needed — the relay binds where amy is trying to
reach it.
2026-04-25 15:58:15 +00:00
davotoula 5861e3a7e5 fix(zoom): keep aspect ratio during the avatar zoom-in animation
The grow animation used independent scaleX/scaleY derived from the source
rect (square avatar) and the image rect (often landscape), so at the start
of the transition the full image was squashed into the avatar's square
footprint before lerping back to its real proportions.

Switches to a uniform scale based on max(src.width/img.width,
src.height/img.height) and centers the image on the tapped rect. The image
now keeps its aspect ratio throughout, covering the source rect in at
least one dimension at progress=0 and growing uniformly to fullscreen.

Also extends the entry guard to require non-zero source rect; without it
a not-yet-measured thumbnail would yield startScale=0 and collapse the
first frame to an invisible point before the animation expanded it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:52:12 +02:00
Claude fa48d7188f docs(cli): slim README, refresh DEVELOPMENT + ROADMAP for USAGE.md split
USAGE.md is now the single home for "how do I use amy" — install,
quick start, examples, command reference, troubleshooting. README
was carrying all of that PLUS the public-contract material; with
USAGE in place it can collapse to just the contract.

README.md (338 → 176 lines):
- Keep: 1-paragraph intro (three audiences), output contract, local
  event-store explainer, relay-routing rules, on-disk layout, cross-
  references to USAGE/DEVELOPMENT/ROADMAP.
- Drop: install, quick start, command reference table, global flags
  table, account-management verbs, troubleshooting (all in USAGE).

DEVELOPMENT.md:
- Architecture diagram updated to reflect new files (Aliases.kt,
  UseCommand.kt, ProfileCommands.kt, DmCommands.kt, NotesCommands /
  PostCommand / FeedCommand, MarmotResetCommand, StoreCommands,
  SecureFileIO, secrets/ subtree).
- "Keep three things in sync" pointers redirected from README's
  command table to USAGE.md.
- Testing table loses the duplicate "Interop with other clients" row
  (already covered by the harness row above).
- Cross-reference list at the top now mentions USAGE.md.

ROADMAP.md:
- Cross-reference list adds USAGE.md.
- Parity matrix marked  for items shipped on this branch:
  notes post + feed (PostCommand/FeedCommand), profile show+edit
  (ProfileCommands), DMs (already ).
- Reactions split into " in groups, 🆕 elsewhere" since
  marmot message react is shipped but outer-event reactions aren't.
- Order-of-operations entries marked  where relevant.
2026-04-25 15:48:32 +00:00
Claude c7d7d88a6c docs(cli): add USAGE.md — user-facing tour
Modeled on nostrcli.sh's docs page: quick start, seven worked examples
(post a note, send a DM, read a thread, view a profile, MLS group
round-trip, account switching, add a relay), categorised command
reference, output-modes section, multi-account workflow, agent/script
recipes, troubleshooting.

README.md and DEVELOPMENT.md are referenced from here; they stay
focused on the public contract and the extension rules.
2026-04-25 15:43:34 +00:00
Crowdin Bot 398e84a498 New Crowdin translations by GitHub Action 2026-04-25 15:43:03 +00:00
Vitor Pamplona c3ca03365f Merge pull request #2569 from davotoula/ci/add-android-lint
ci: add Android Lint as first step of the Android job
2026-04-25 11:41:38 -04:00
Claude e17ef42e54 fix(cli): rename global --name to --account to free --name for subcommands
The marmot harness surfaced the bug on first run: amy stripped
`marmot group create --name "Interop-02"` thinking the global
account selector ran into the group's display-name flag. Result:
amy resolved the "Interop-02" account, found no identity.json, and
errored — no group ever got created.

Renames the global account-selector flag to `--account`. The per-
subcommand `--name` flags (`marmot group create --name "Demo"`,
`profile edit --name "Alice"`, `marmot await group --name X`) are
untouched — they're free of the global parser now that it doesn't
claim the same name.

Sweep:
- `Main.kt`: GlobalFlag.NAME → ACCOUNT, long "--account".
- `Config.kt`: DataDir.resolve param renamed nameFlag → accountFlag;
  every error message points the user at --account.
- `UseCommand.kt`: error hint says `amy --account NAME init`.
- All test wrappers + direct $AMY_BIN calls under cli/tests/ swap
  the global `--name X` for `--account X` (subcommand --name kept
  exactly where it appeared).
- README + DEVELOPMENT updated.
2026-04-25 15:26:17 +00:00
Claude 99586fbe7e feat(cli): drop --data-dir; tests isolate via $HOME override
There is no installed base to protect, so the self-contained
`--data-dir P` escape hatch goes away entirely. amy now has exactly
one layout — the production multi-account one — and tests exercise
that same code path. Drops one global flag, one DataDir construction
mode, and the "tests use a different code path than users" footgun.

Layout (unchanged from the previous commits — just the only mode now):

    ~/.amy/
    ├── current                      # `amy use NAME` marker
    ├── shared/
    │   └── events-store/            # FsEventStore, one per machine
    ├── alice/
    │   ├── identity.json
    │   ├── state.json
    │   ├── aliases.json             # {"alice": "<own npub>"} after init
    │   └── marmot/
    └── bob/ ...

`DataDir.DEFAULT_ROOT` reads `$HOME` directly (falling back to
`user.home`) because JDK 21 resolves `user.home` via getpwuid and
ignores `$HOME` — which would have broken the standard CLI test
isolation pattern of `HOME=/tmp/foo amy …` (the same convention
git, gpg, npm follow).

Test sweep:

- `cli/tests/headless/helpers.sh`, `cli/tests/dm/setup.sh`,
  `cli/tests/cache/cache-headless.sh` wrappers all switch to
  `HOME=$STATE_DIR amy --name X …`.
- `ensure_identity_for` drops its `dir` parameter; the function and
  every harness call site go through `--name` only.
- `A_DIR`/`B_DIR`/`D_DIR` get repointed at `$STATE_DIR/.amy/X`. The
  one consumer (`cache-headless.sh`'s T6 `relays.json` check) still
  works since it's just a path probe.
- `cli/tests/dm/tests-dm.sh` ghost identities get their own
  short-lived `$HOME` so they don't pollute the main test root.

Cache-test T4 inverts: pre-shared-store, "B has not seen A → first
profile show is a relay miss, second is a cache hit" tested
per-account caching. With one shared events-store, B's first lookup
of A is already a cache hit because A wrote kind:0 there during
bootstrap. T4 now asserts that — drops the stale "second lookup hits
cache" half. T7 (no-identity maintenance verbs) gets a fresh fake
`$HOME` plus a throwaway `--name` so the empty store has no inherited
events.

Docs (README + DEVELOPMENT) rewritten to match — quick-start now uses
`amy --name alice create`, the on-disk-layout section shows the new
tree, and the global-flags table replaces `--data-dir` with `--name`
plus the new `amy use` verb.
2026-04-25 15:07:31 +00:00
davotoula fd1776fd07 ci: add Android Lint as first step of the Android job
Android Lint catches API-level, deprecation, accessibility, and resource
issues that spotless and unit tests miss. Run lint on the same variants
that get assembled (fdroidBenchmark, playBenchmark) before tests so a
lint failure surfaces quickly without consuming the full test budget.

Lint reports are uploaded as artifacts for inspection.
2026-04-25 17:04:50 +02:00
Vitor Pamplona 1876941d8f Merge pull request #2567 from vitorpamplona/claude/review-quartz-sqlite-store-xBzaq
Implement NIP-01 lexical id tiebreaker and NIP-09 author-only deletion
2026-04-25 10:41:09 -04:00
Claude 46bdd59ead feat(cli): account auto-pick + amy use to pin the active account
Resolution order in account mode (when --data-dir is not set):

  1. --name X if given.
  2. ~/.amy/current marker (set by `amy use X`).
  3. Sole subdirectory of ~/.amy/ other than shared/.
  4. Error — disambiguate with --name or `amy use`.

Single-account users skip the flag entirely (`amy whoami` Just Works
once one account exists). Multi-account users either pin one with
`amy use bob` (writes ~/.amy/current) or pass --name on every call.
The pinned account can be overridden by --name on a single command,
and cleared with `amy use --clear`.

`amy use` (no arg) prints the current pin plus the list of available
accounts. `amy use NAME` validates the name, requires the account dir
to already exist (else `no_account` with a creation hint), and
atomically writes the marker.

The auto-pick errors are deliberately self-explaining:
- 0 accounts → "no account at ~/.amy; create one with `amy --name X init`"
- 2+ accounts unpinned → "multiple accounts (alice, bob); pick one
  with --name or `amy use <name>`"
- stale current marker → "current pins 'ghost' but ~/.amy/ghost
  doesn't exist; rewrite with `amy use <name>` or pass --name"

`use` is dispatched before DataDir.resolve so it works even when the
auto-pick would fail — that's the whole point of having the verb.
The `name` field also lands in `whoami` output now (null in
--data-dir legacy mode).
2026-04-25 14:36:54 +00:00
Claude 076859ca2d feat(cli): introduce ~/.amy account-mode layout + --name flag
Default on-disk layout becomes:

    ~/.amy/
    ├── shared/
    │   └── events-store/        (lazy: created on first event)
    └── <account>/               (created by `amy --name X init`)
        ├── identity.json
        ├── state.json
        ├── aliases.json
        └── marmot/

Per-account state moves under `~/.amy/<account>/`; the events-store is
shared across accounts under `~/.amy/shared/`. The shared store is
safe to share for now because amy doesn't currently persist any
decrypted inner events to it (NIP-17 DMs unwrap-and-display in
DmCommands; MLS inner events go to the per-group .log under
<account>/marmot/groups/, not the event store). When that changes,
a follow-up will introduce a per-account private-events-store and a
composite reader.

A new `--name X` global flag selects (or creates) `~/.amy/X/`.
`--data-dir P` is preserved as a self-contained escape hatch — the
test harness and ad-hoc throwaway dirs use it, events-store stays
inside P. Pass exactly one of `--name` or `--data-dir`; passing both
or neither is bad_args (exit 2). Names must match
[a-zA-Z0-9_-]{1,64}; `shared` is reserved.

`amy init --name alice` writes a self-entry into
`<account>/aliases.json` ({"alice":"npub1…"}) so future commands and
the planned `amy alias add` / dm-recipient resolver can refer to the
account by name. The init result map gains a `name` key (null in
legacy mode).

Open question for a follow-up: when only one account exists in
~/.amy/, should `amy whoami` work without --name? Today it doesn't —
strict mode. Also pending: README rewrite, plus a sweep through
commands that print `data_dir` to also surface `name` where useful.
2026-04-25 14:32:19 +00:00
Claude 75bcd77914 docs(quartz/store): note the NIP-01 tiebreaker, NIP-09 created_at window, and author-check on deletion in both store READMEs
https://claude.ai/code/session_01X163Nr31vGkvAXoJ3JgMov
2026-04-25 14:30:59 +00:00
Claude 819322bbf9 fix(quartz/fs): port the same NIP correctness fixes from the sqlite store
Three of the bugs that the SQLite review surfaced also live in the
filesystem-backed event store. Bringing both stores back to parity:

- NIP-01 lexical-id tiebreaker (FsSlots, FsEventStore): when two
  replaceables / addressables share `createdAt`, the lexically smaller
  id wins. The previous `existing.createdAt >= incoming.createdAt`
  check rejected equal-timestamp inserts unconditionally — which
  blocks the legitimate winner whenever it arrived second.

- delete(Filter()) safe-by-default (FsEventStore): an empty filter
  used to enumerate every event and delete each one, so a stray
  `delete(Filter())` would wipe the entire on-disk store. Now both
  the single-filter and list-of-filters overloads short-circuit when
  every filter is empty, matching the SQLiteEventStore contract.

- NIP-09 author check on tombstone install (FsEventStore,
  FsTombstones): SQLite's `reject_deleted_events` trigger checks
  `event_tags.pubkey_hash = NEW.pubkey_owner_hash`, so a stranger's
  kind-5 with an `e`/`a` tag pointing at someone else's event must
  not block them from re-publishing. The FS store used to install
  the tombstone unconditionally and then read it back without an
  author check.
  - id tombstones still install (so they can fire when the deletion
    arrives before its target), but `idTombstoneOwnerPubKey` is now
    compared against the candidate event's owner pubkey at insert
    time. GiftWrap parity preserved via FsIndexer.ownerPubKey, which
    returns the recipient like the SQLite `pubkey_owner_hash`.
  - addr tombstones are now only installed when
    `addr.pubKeyHex == deletion.pubKey`, since the address itself
    carries the owner identity.

Tests:
- FsSlotsTest: replaces "equal timestamp replaceable is rejected"
  (which pinned the buggy behaviour) with two tests covering the
  lexical-id tiebreaker in both insertion orders.
- FsParityTest: new same-`createdAt` tiebreaker tests for both
  replaceable and addressable kinds, asserting FS and SQLite agree.
- FsDeletionTest:
  - inverts "deletion by non-author does not cascade but still
    installs id tombstone" — the legitimate owner must be able to
    re-insert after the stranger's deletion.
  - new test that a stranger's `a`-tag deletion does not block
    the legitimate addressable owner from publishing a new version.
- FsEventStoreTest: new `delete with empty filter is safe` test
  matching the SQLite-side contract added in batch A.

https://claude.ai/code/session_01X163Nr31vGkvAXoJ3JgMov
2026-04-25 14:24:28 +00:00
David Kaspar 03e4dca28b Merge pull request #2566 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-25 16:08:27 +02:00
Claude 9f83feff47 test(quartz/sqlite): cover transaction rollback, deletion permissions, vanish scope, FTS rotation, vacuum, SQL DSL
Batch B test-coverage gaps from the review:

- testTransactionRollsBackOnTriggerAbort: a multi-insert transaction
  whose middle statement is rejected by `reject_deleted_events` rolls
  back ALL inserts in the transaction, not just the failing one.

- testDeletionByThirdPartyDoesNothing: NIP-09 author check —
  another user's deletion event must not remove the original.

- testKind5CanBeDeletedByAnotherKind5OfSameAuthor: pins the current
  behavior that kind-5 events are not specially protected; a same-author
  follow-up deletion can remove a previous one, and re-insertion of the
  removed deletion is then blocked.

- testGiftWrapDeletionRequiresRecipient: NIP-59 GiftWraps key on the
  recipient (p-tag), not the (encrypted) inner author. Sender and
  unrelated third parties cannot delete; the recipient can.

- testVanishForDifferentRelayIsNoOp: a kind-62 vanish naming a
  different relay must be stored but must not delete events on this
  relay nor block new inserts (RightToVanishModule.shouldVanishFrom
  contract).

- testFtsCleanedUpAfterReplaceableRotation: FTS rows are cleaned via
  the AFTER DELETE trigger when an addressable is superseded — old
  content must no longer match search.

- testVacuumAndAnalyseSmoke: VACUUM and ANALYZE run on a populated DB
  without throwing and preserve existing rows.

- SqlSelectionBuilderTest: pins NotEquals(null) → IS NOT NULL,
  empty IN → "1 = 0", equalsOrIn singleton/multiple paths.

https://claude.ai/code/session_01X163Nr31vGkvAXoJ3JgMov
2026-04-25 13:56:28 +00:00
Crowdin Bot 1a93bb52f4 New Crowdin translations by GitHub Action 2026-04-25 13:52:34 +00:00
Vitor Pamplona 857d19f582 Merge pull request #2564 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-25 09:51:09 -04:00
Vitor Pamplona a8a267694d Merge pull request #2565 from davotoula/ci/split-android-job
Ci/split android job
2026-04-25 09:50:53 -04:00
Claude cd0b43afd9 fix(cli): expand embedded JsonNode in text-mode renderer
`profile show` puts the parsed kind:0 content under `metadata` as a
Jackson `JsonNode` (`ProfileCommands.kt:114`). The text renderer only
recursed into `Map`/`List`, so the JsonNode fell through to
`toString()` and printed as a single quoted-JSON line:

    metadata:       {"name":"Alice","picture":"…",…}

Convert any embedded JsonNode to plain Java types via
`mapper.convertValue` once at the top of `renderText`, so the same
generic walk yields:

    metadata:
      name:    Alice
      picture: …
      …

The walk handles nested cases (a hand-built Map containing a JsonNode
subtree, an ArrayNode inside a List, etc.). The `--json` shape is
untouched — Jackson's `writeValueAsString` already serialises JsonNode
natively.
2026-04-25 13:46:55 +00:00
David Kaspar 808a313e75 Merge branch 'vitorpamplona:main' into ci/split-android-job 2026-04-25 15:45:55 +02:00
Claude e9e994fff4 fix(quartz/sqlite): Batch A correctness/consistency fixes
- isExpired (#10): NIP-40 says an event is expired *once* `expiration`
  is reached, and the SQL trigger uses `<= unixepoch()`. The Kotlin
  pre-check used `<` (strict) so an event with `expiration == now`
  passed the Kotlin check then failed in the trigger. Both layers now
  use `<=`. Also applies to `isExpirationBefore` for consistency.

- transaction extension (#7): if the body throws *and* ROLLBACK also
  throws, we now attach the rollback failure as a suppressed exception
  instead of letting it mask the original cause. COMMIT is moved outside
  the catch so a commit failure doesn't trigger a second ROLLBACK on
  already-finalized transaction state.

- SeedModule.hasher (#8): the cache field is now a `kotlin.concurrent.
  atomics.AtomicReference` (matches the pattern used in BleChunkAssembler
  and BasicRelayClient) so the hasher publication is visible across
  threads. The race itself is benign — the seed is stable, so two
  concurrent computations produce identical hashers — but the prior
  plain `var` had no visibility guarantee.

- delete(Filter()) (#12): documents the intentional asymmetry — `query`
  on an empty filter returns everything, but `delete` on an empty
  filter is a no-op (safe-by-default). New test pins the contract.

Tests:
- testInsertingEventExpiringExactlyNow: events with `expiration == now`
  are rejected by both Kotlin and the trigger.
- testTransactionRollsBackOnException: a user transaction whose body
  throws leaves the DB unchanged and still accepts new writes.
- testDeleteWithEmptyFilterIsSafe: empty-filter delete is a no-op.

https://claude.ai/code/session_01X163Nr31vGkvAXoJ3JgMov
2026-04-25 13:31:16 +00:00
Claude ce3a82ab3d feat(cli): colour amy's text output and humanise scalar values
Defaults to ANSI colour when stdout is a TTY (off when piped, off under
NO_COLOR=…, force-on under CLICOLOR_FORCE=1). Layout improvements that
apply to every command without per-command code:

- Sibling keys at each indentation level pad to the widest, so colons
  line up YAML-style.
- Keys render bold; list dashes and "(none)"/"(empty)" markers render
  dim.
- Booleans render as `yes` / `no` (green / red).
- Integer values under `*_at` keys render as
  `2026-04-25 12:30:45Z (2m ago)` instead of a raw epoch second.
- Integer values under `*_bytes` keys (and `size`) render as
  `7.0 KiB` / `1.2 MiB` / etc.
- Errors render `error: <code>: <detail>` with the prefix bold-red and
  the code yellow.

The `--json` shape and exit codes are untouched; the smart formatting
only changes how scalars surface in the human-text mode.
2026-04-25 13:22:15 +00:00
davotoula adfdfa45cc test(quartz): also normalize CRLF on Jackson output
Jackson's pretty-printer can emit \r\n on Windows depending on the
configured DefaultIndenter, so the previous one-sided normalization
still mismatched. Normalize both sides of the assert to LF.
2026-04-25 15:16:37 +02:00
davotoula 680b1f0ef6 test(quartz): normalize CRLF in pretty-printer string asserts
Triple-quoted Kotlin strings inherit the source file's line endings,
so on Windows checkouts (CRLF) the expected JSON contains \r\n while
Jackson's pretty-printer always outputs \n. Normalize the expected
side with replace("\r\n", "\n") so the assertion is platform-neutral.

Surfaced by adding :quartz:jvmTest to the desktop CI matrix; previously
the bare 'test' lifecycle didn't resolve jvmTest in KMP modules so the
mismatch was never observed on Windows.
2026-04-25 15:01:33 +02:00
Claude 28b23b5e63 fix(quartz/sqlite): NIP compliance and migration safety in event store
- NIP-09 (#2): a-tag and replaceable deletes now respect
  `created_at <= deletion.created_at` so a stale deletion request
  cannot remove a newer addressable that legitimately replaced it.
  `+created_at` hint on the addressable path keeps the d_tag-selective
  index in use.

- NIP-01 (#1): replaceable / addressable triggers now apply the
  lexical-id tiebreaker — when two events share `created_at`, the
  one with the lexicographically smaller id wins, matching the spec.

- Schema (#5): FullTextSearchModule.versionFinder probes FTS support
  with a dummy table, but used to leave it behind. The first v1->v2
  upgrade then failed because re-running create() would hit
  "already exists". Now we drop the probe table immediately and
  defensively clean up any stragglers.

- Schema (#6): onCreate / onUpgrade and the matching `setUserVersion`
  are now wrapped in a single transaction so a partial migration
  cannot leave the DB with mismatched user_version and schema.

- SQL DSL (#4): Condition.NotEquals(null) now produces `IS NOT NULL`
  instead of `IS NULL`.

- Doc fix (#13): swapped vacuum/analyse comments now describe the
  right command.

Tests: same-`created_at` tiebreaker for replaceables and addressables,
NIP-09 created_at window for a-tag deletes, schema drop+recreate
idempotency (covers the FTS dummy-table regression).

https://claude.ai/code/session_01X163Nr31vGkvAXoJ3JgMov
2026-04-25 12:32:11 +00:00
Claude 03eb7be509 feat(cli): default amy stdout to human-readable text; --json opts in
amy's default stdout is now a YAML-ish render of the underlying result
map; the previous single-line JSON contract moves behind a global
`--json` flag. Errors mirror the same rule (`error: <code>: <detail>`
on stderr by default, JSON `{"error":...,"detail":...}` under --json).
Exit codes (0/1/2/124) and the --json shape itself are unchanged —
only the default presentation flips.

- Replaces Json.writeLine / Json.error with mode-aware
  Output.emit / Output.error. The same Jackson mapper is reused for
  on-disk JSON via Output.mapper.
- Adds `--json` to the global flag set in Main.kt; honoured even when
  argument parsing fails so error JSON keeps shape under --json.
- Updates the test harness wrappers (amy_a / amy_b / amy_d in
  cli/tests/{headless,dm,cache}/) and the few direct $AMY_BIN call
  sites whose stdout is consumed via $() / 2>&1 — they now pass
  --json so the existing jq pipelines keep working.
- Rewrites the README "Output contract" and DEVELOPMENT design
  principles to describe the new default, and clarifies that only
  --json is the public API; the text shape is allowed to drift.
2026-04-25 12:23:37 +00:00