Files
amethyst/quartz/src
Claude 400c15ff44 feat(quartz): NIP-FE relay commands over HTTP, and the engine seams it needs
A relay can now answer one client command per HTTP request (POST /req,
/count, /event, /neg) with its own NIP-01 frames, one per line, ending
on the command's answer, through the same session, policies and backend
as its websocket. The spec draft is NIP-FE, "Relay Commands over HTTP".

Engine (nip01Core/relay/server), source- and binary-compatible:

- SessionSink: where a session's frames go, typed (Message) wherever the
  engine built one and raw for the spliced EVENT frames. A transport can
  now end an answer at its EOSE or map a CLOSED onto a status without
  re-parsing wire JSON. SessionSink.of(send) is the old string callback,
  and RelaySession/RelayServerBase keep every (String) -> Unit overload.
- RelaySession.initialAuthenticatedUsers, RelayServerBase.connect(sink,
  authenticatedUsers) and serve(sink, authenticatedUsers, incoming): a
  transport that proved a key itself (NIP-98) opens the session signed
  in, recorded exactly where a NIP-42 AUTH would record it, so every
  policy and backend that reads RequestContext.authenticatedUsers sees
  it with no side table.
- RelaySession.receive(Command): dispatch an already-parsed command. The
  string overload parses and calls it; acceptMessage still guards text.

NIP-FE (nipFERelayOverHttp), common code, no HTTP library:

- HttpRelayCommand: the four paths, each body parsed into its typed
  Command through a JSON tree (so a body can only ever be arguments),
  and which Message ends each answer.
- HttpRelayStatus: the status an answer's first frame gives it, from
  its NIP-01 prefix; a duplicate: OK is a 200.
- HttpRelayHandler: size, parse, NIP-98 (payload-bound, single-use, its
  own replay cache, any of the relay's addresses, other schemes
  ignored), then the exchange: first frame decides the status, the rest
  streams through HttpRelayResponse/HttpRelayLines, flushed whenever no
  frame is waiting. The deadline is read between frames and never
  interrupts a write; a reader that stops reading is HttpRelayReaderStalled
  past a grace, and the host drops it. Admission stays with the host, in
  front of handle(), so a refused request never spends a token.
- NEG is one stateless NIP-77 round, [filter, message]: the responder's
  only state is its sealed snapshot, which the backend already caches
  per filter, so nothing is held between rounds and no NEG-CLOSE exists.

Not in this change: backpressure from the socket into the backend. The
store callbacks cannot suspend, so a reader more than maxQueuedFrames
behind is cut with a CLOSED line, the websocket's bound.

Verified with :quartz:jvmTest (HttpRelayHandlerTest, 13 cases over a fake
in-memory SessionBackend), and earlier against the a8e8778265 jar that
vespa-relay pins, where every call the rest of the jar makes into
RelaySession/RelayServerBase still links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jDNhr6a3J4VC5TaG3Yd48
2026-09-27 01:38:37 +00:00
..