Re-vendor the canonical files from sneak/prompts at dd4027b (closes #112) #115

Merged
clawbot merged 3 commits from issue-112-revendor-prompts into next 2026-10-06 21:01:47 +02:00
5 changed files with 193 additions and 12 deletions
Showing only changes of commit 58d8f5cc94 - Show all commits
+1 -2
View File
@@ -24,8 +24,6 @@ RUN golangci-lint run --config .golangci.yml ./...
# Test phase, built alone by script/test. -race needs cgo and so a C # Test phase, built alone by script/test. -race needs cgo and so a C
# compiler, which the Debian Go image ships and the alpine one does not. # compiler, which the Debian Go image ships and the alpine one does not.
# -p 4 runs at most four test binaries at once: under -race each one
# costs a few hundred MB, and the default is one per core.
# golang:1.24.13-bookworm, 2026-10-06 # golang:1.24.13-bookworm, 2026-10-06
FROM golang@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac AS test FROM golang@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac AS test
WORKDIR /src WORKDIR /src
@@ -33,6 +31,7 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js
# -p 4 because test runs on a shared build host cap their parallelism.
RUN go test -p 4 -timeout 90s -race -cover ./... || \ RUN go test -p 4 -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -p 4 -timeout 90s -race -v ./...; exit 1; } go test -p 4 -timeout 90s -race -v ./...; exit 1; }
+19 -9
View File
@@ -164,8 +164,9 @@ for multi-client access.
cryptographically random value (64 hex characters) and returns the user ID and cryptographically random value (64 hex characters) and returns the user ID and
nick in the JSON response body. No auth credential appears in the JSON body. nick in the JSON response body. No auth credential appears in the JSON body.
- The auth cookie is HttpOnly, SameSite=Strict, and Secure: clients send it only - The auth cookie is HttpOnly, SameSite=Strict, and Secure: clients send it only
over HTTPS, which the TLS-terminating reverse proxy provides. Browsers handle over HTTPS, which the TLS-terminating reverse proxy provides, or to a server
cookies automatically. **CLI clients (curl, custom HTTP clients) must on `localhost` (see [Transport Security](#transport-security)). Browsers
handle cookies automatically. **CLI clients (curl, custom HTTP clients) must
explicitly save and send cookies** — e.g., using curl's `-c`/`-b` flags or an explicitly save and send cookies** — e.g., using curl's `-c`/`-b` flags or an
HTTP cookie jar in their language's HTTP library. HTTP cookie jar in their language's HTTP library.
- Sessions start anonymous — no password required. When the session expires or - Sessions start anonymous — no password required. When the session expires or
@@ -2092,9 +2093,14 @@ PGP/DKIM — the mail server sees everything, but signatures prove authenticity.
### Transport Security ### Transport Security
- **HTTPS is strongly recommended** for production deployments. The server - **Clients need HTTPS to keep a session.** The auth cookie is always `Secure`,
itself serves plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS and clients send a `Secure` cookie only over HTTPS. The server itself serves
termination. plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS termination.
- **A server on `localhost`**, as in this document's examples, also works over
plain HTTP with curl, `neoirc-cli`, Chrome and Firefox, which treat
`localhost` as secure. Safari does not. Python's `requests` does not either,
so the [Python example](#implementing-long-poll-in-code) sends the cookie
itself.
- **CORS**: The server allows all origins with credentials - **CORS**: The server allows all origins with credentials
(`Access-Control-Allow-Credentials: true`), reflecting the request Origin. (`Access-Control-Allow-Credentials: true`), reflecting the request Origin.
This enables cookie-based auth from cross-origin clients. Restrict origins in This enables cookie-based auth from cross-origin clients. Restrict origins in
@@ -2428,7 +2434,8 @@ docker run -d \
This repository adheres to the This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard. Each script below has a `make` target of the same name that calls it, standard. Each script below has a `make` target of the same name that calls it,
except `script/install-precommit`, which is `make hooks`. except `script/install-precommit`, which is `make hooks`, and `script/cibuild`,
`script/precommit` and `script/projectname`, which have none.
- `script/bootstrap`: installs what development needs: make, git, Node and yarn - `script/bootstrap`: installs what development needs: make, git, Node and yarn
(for prettier), and Go. (for prettier), and Go.
@@ -2625,16 +2632,19 @@ curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
The key to real-time messaging is the poll loop. Here's the pattern: The key to real-time messaging is the poll loop. Here's the pattern:
```python ```python
# Python example — using requests.Session for automatic cookie handling # Python example — using requests.Session for cookie handling
import requests, json, time import requests, json, time
BASE = "http://localhost:8080/api/v1" BASE = "http://localhost:8080/api/v1"
session = requests.Session() # Manages cookies automatically session = requests.Session()
last_id = 0 last_id = 0
# Create session (cookie set automatically via Set-Cookie header) # Create session (the server sets the auth cookie via Set-Cookie)
resp = session.post(f"{BASE}/session", json={"nick": "pybot"}) resp = session.post(f"{BASE}/session", json={"nick": "pybot"})
print(f"Session: {resp.json()}") print(f"Session: {resp.json()}")
# The auth cookie is Secure, and requests sends it only over HTTPS.
# For a plain-HTTP server on localhost, send it on every request:
session.headers["Cookie"] = f"neoirc_auth={resp.cookies['neoirc_auth']}"
# Join channel # Join channel
session.post(f"{BASE}/messages", session.post(f"{BASE}/messages",
+25 -1
View File
@@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"net/url" "net/url"
@@ -41,11 +42,34 @@ func NewClient(baseURL string) *Client {
BaseURL: baseURL, BaseURL: baseURL,
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine
Timeout: httpTimeout, Timeout: httpTimeout,
Jar: jar, Jar: loopbackJar{CookieJar: jar},
}, },
} }
} }
// loopbackJar also sends the server's auth cookie, which is
// always Secure, over plain HTTP to a server on localhost, as
// curl does. Go 1.24's cookie jar sends a Secure cookie only
// over HTTPS.
type loopbackJar struct {
http.CookieJar
}
// Cookies returns the cookies to send to target, treating a
// plain-HTTP target on localhost as HTTPS.
func (jar loopbackJar) Cookies(target *url.URL) []*http.Cookie {
host := target.Hostname()
loopback := host == "localhost" || net.ParseIP(host).IsLoopback()
if target.Scheme == "http" && loopback {
secure := *target
secure.Scheme = "https"
target = &secure
}
return jar.CookieJar.Cookies(target)
}
// CreateSession creates a new session on the server. // CreateSession creates a new session on the server.
// If the server requires hashcash proof-of-work, it // If the server requires hashcash proof-of-work, it
// automatically fetches the difficulty and computes a // automatically fetches the difficulty and computes a
+97
View File
@@ -0,0 +1,97 @@
package neoircapi_test
import (
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"testing"
api "sneak.berlin/go/neoirc/internal/cli/api"
)
const cookieValue = "opaque-value"
// newSessionServer starts a plain-HTTP server that, like
// neoircd, sets a Secure auth cookie when a session is
// created and answers GET /api/v1/state only when that
// cookie comes back.
func newSessionServer(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/server", func(
writer http.ResponseWriter, _ *http.Request,
) {
_, _ = io.WriteString(writer, `{}`)
})
mux.HandleFunc("POST /api/v1/session", func(
writer http.ResponseWriter, _ *http.Request,
) {
http.SetCookie(writer, &http.Cookie{
Name: "neoirc_auth",
Value: cookieValue,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
writer.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(writer, `{"id":1,"nick":"alice"}`)
})
mux.HandleFunc("GET /api/v1/state", func(
writer http.ResponseWriter, request *http.Request,
) {
cookie, err := request.Cookie("neoirc_auth")
if err != nil || cookie.Value != cookieValue {
writer.WriteHeader(http.StatusUnauthorized)
return
}
_, _ = io.WriteString(
writer, `{"id":1,"nick":"alice","channels":[]}`,
)
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return server
}
func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) {
t.Parallel()
server := newSessionServer(t)
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
for _, host := range []string{"127.0.0.1", "localhost"} {
t.Run(host, func(t *testing.T) {
t.Parallel()
client := api.NewClient(
"http://" + net.JoinHostPort(host, serverURL.Port()),
)
_, err := client.CreateSession("alice")
if err != nil {
t.Fatalf("create session: %v", err)
}
_, err = client.GetState()
if err != nil {
t.Fatalf("state after creating the session: %v", err)
}
})
}
}
+51
View File
@@ -7,6 +7,7 @@ import (
"log/slog" "log/slog"
"net" "net"
"os" "os"
"runtime"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -608,6 +609,56 @@ func TestNamesNonExistentChannel(t *testing.T) {
) )
} }
// TestRelayStopsWhenConnectionCloses checks that closing a
// registered client's connection stops the goroutine that
// relays its messages.
//
//nolint:paralleltest // counts every goroutine in the process
func TestRelayStopsWhenConnectionCloses(t *testing.T) {
env := newTestEnv(t)
client := env.dial(t)
client.register("relaystop")
waitForRelayGoroutines(t, 1)
err := client.conn.Close()
if err != nil {
t.Fatalf("close: %v", err)
}
waitForRelayGoroutines(t, 0)
}
const (
stackDumpSize = 1 << 20
relayCheckStep = 10 * time.Millisecond
)
// waitForRelayGoroutines waits until exactly want goroutines
// are running the relay loop, and fails the test if that does
// not happen within testTimeout.
func waitForRelayGoroutines(t *testing.T, want int) {
t.Helper()
deadline := time.Now().Add(testTimeout)
stacks := make([]byte, stackDumpSize)
for {
n := runtime.Stack(stacks, true)
got := strings.Count(string(stacks[:n]), "(*Conn).relayMessages(")
if got == want {
return
}
if time.Now().After(deadline) {
t.Fatalf("relay goroutines: got %d, want %d", got, want)
}
time.Sleep(relayCheckStep)
}
}
func BenchmarkParseMessage(b *testing.B) { func BenchmarkParseMessage(b *testing.B) {
line := ":nick!user@host PRIVMSG #channel :Hello, world!" line := ":nick!user@host PRIVMSG #channel :Hello, world!"