refactor: move hashcash stamp from X-Hashcash header to JSON request body

Move the hashcash proof-of-work stamp from the X-Hashcash HTTP header
into the JSON request body as a 'hashcash' field on POST /api/v1/session.

Updated server handler, CLI client, SPA client, and documentation.
This commit is contained in:
clawbot
2026-03-10 10:00:20 -07:00
committed by clawbot
parent a586e92dad
commit 82bff502fe
5 changed files with 52 additions and 59 deletions

View File

@@ -989,23 +989,18 @@ Create a new user session. This is the entry point for all clients.
If the server requires hashcash proof-of-work (see If the server requires hashcash proof-of-work (see
[Hashcash Proof-of-Work](#hashcash-proof-of-work)), the client must include a [Hashcash Proof-of-Work](#hashcash-proof-of-work)), the client must include a
valid stamp in the `X-Hashcash` request header. The required difficulty is valid stamp in the `hashcash` field of the JSON request body. The required
advertised via `GET /api/v1/server` in the `hashcash_bits` field. difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
**Request Headers:**
| Header | Required | Description |
|--------------|----------|-------------|
| `X-Hashcash` | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
**Request Body:** **Request Body:**
```json ```json
{"nick": "alice"} {"nick": "alice", "hashcash": "1:20:260310:neoirc::3a2f1"}
``` ```
| Field | Type | Required | Constraints | | Field | Type | Required | Constraints |
|--------|--------|----------|-------------| |------------|--------|-------------|-------------|
| `nick` | string | Yes | 132 characters, must be unique on the server | | `nick` | string | Yes | 132 characters, must be unique on the server |
| `hashcash` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
**Response:** `201 Created` **Response:** `201 Created`
```json ```json
@@ -1027,7 +1022,7 @@ advertised via `GET /api/v1/server` in the `hashcash_bits` field.
| Status | Error | When | | Status | Error | When |
|--------|-------|------| |--------|-------|------|
| 400 | `nick must be 1-32 characters` | Empty or too-long nick | | 400 | `nick must be 1-32 characters` | Empty or too-long nick |
| 402 | `hashcash proof-of-work required` | Missing `X-Hashcash` header when hashcash is enabled | | 402 | `hashcash proof-of-work required` | Missing `hashcash` field in request body when hashcash is enabled |
| 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) | | 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) |
| 409 | `nick already taken` | Another active session holds this nick | | 409 | `nick already taken` | Another active session holds this nick |
@@ -1035,8 +1030,7 @@ advertised via `GET /api/v1/server` in the `hashcash_bits` field.
```bash ```bash
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \ TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-H 'X-Hashcash: 1:20:260310:neoirc::3a2f1' \ -d '{"nick":"alice","hashcash":"1:20:260310:neoirc::3a2f1"}' | jq -r .token)
-d '{"nick":"alice"}' | jq -r .token)
echo $TOKEN echo $TOKEN
``` ```
@@ -2138,7 +2132,7 @@ account registration, no IP-based rate limits that punish shared networks.
2. Client computes a hashcash stamp: find a counter value such that the 2. Client computes a hashcash stamp: find a counter value such that the
SHA-256 hash of the stamp string has the required number of leading zero SHA-256 hash of the stamp string has the required number of leading zero
bits. bits.
3. Client includes the stamp in the `X-Hashcash` request header when creating 3. Client includes the stamp in the `hashcash` field of the JSON request body when creating
a session: `POST /api/v1/session`. a session: `POST /api/v1/session`.
4. Server validates the stamp: 4. Server validates the stamp:
- Version is `1` - Version is `1`
@@ -2195,7 +2189,7 @@ Both the embedded web SPA and the CLI client automatically handle hashcash:
1. Fetch `GET /api/v1/server` to read `hashcash_bits` 1. Fetch `GET /api/v1/server` to read `hashcash_bits`
2. If `hashcash_bits > 0`, compute a valid stamp 2. If `hashcash_bits > 0`, compute a valid stamp
3. Include the stamp in the `X-Hashcash` header on `POST /api/v1/session` 3. Include the stamp in the `hashcash` field of the JSON body on `POST /api/v1/session`
The web SPA uses the Web Crypto API (`crypto.subtle.digest`) for SHA-256 The web SPA uses the Web Crypto API (`crypto.subtle.digest`) for SHA-256
computation with batched parallelism. The CLI client uses Go's `crypto/sha256`. computation with batched parallelism. The CLI client uses Go's `crypto/sha256`.

View File

@@ -52,7 +52,7 @@ func (client *Client) CreateSession(
// Fetch server info to check for hashcash requirement. // Fetch server info to check for hashcash requirement.
info, err := client.GetServerInfo() info, err := client.GetServerInfo()
var headers map[string]string var hashcashStamp string
if err == nil && info.HashcashBits > 0 { if err == nil && info.HashcashBits > 0 {
resource := info.Name resource := info.Name
@@ -60,17 +60,13 @@ func (client *Client) CreateSession(
resource = "neoirc" resource = "neoirc"
} }
stamp := MintHashcash(info.HashcashBits, resource) hashcashStamp = MintHashcash(info.HashcashBits, resource)
headers = map[string]string{
"X-Hashcash": stamp,
}
} }
data, err := client.doWithHeaders( data, err := client.do(
http.MethodPost, http.MethodPost,
"/api/v1/session", "/api/v1/session",
&SessionRequest{Nick: nick}, &SessionRequest{Nick: nick, Hashcash: hashcashStamp},
headers,
) )
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -4,7 +4,8 @@ import "time"
// SessionRequest is the body for POST /api/v1/session. // SessionRequest is the body for POST /api/v1/session.
type SessionRequest struct { type SessionRequest struct {
Nick string `json:"nick"` Nick string `json:"nick"`
Hashcash string `json:"hashcash,omitempty"`
} }
// SessionResponse is the response from session creation. // SessionResponse is the response from session creation.

View File

@@ -145,35 +145,9 @@ func (hdlr *Handlers) handleCreateSession(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
) { ) {
// Validate hashcash proof-of-work if configured.
if hdlr.params.Config.HashcashBits > 0 {
stamp := request.Header.Get("X-Hashcash")
if stamp == "" {
hdlr.respondError(
writer, request,
"hashcash proof-of-work required",
http.StatusPaymentRequired,
)
return
}
err := hdlr.hashcashVal.Validate(
stamp, hdlr.params.Config.HashcashBits,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid hashcash stamp: "+err.Error(),
http.StatusPaymentRequired,
)
return
}
}
type createRequest struct { type createRequest struct {
Nick string `json:"nick"` Nick string `json:"nick"`
Hashcash string `json:"hashcash,omitempty"`
} }
var payload createRequest var payload createRequest
@@ -189,6 +163,32 @@ func (hdlr *Handlers) handleCreateSession(
return return
} }
// Validate hashcash proof-of-work if configured.
if hdlr.params.Config.HashcashBits > 0 {
if payload.Hashcash == "" {
hdlr.respondError(
writer, request,
"hashcash proof-of-work required",
http.StatusPaymentRequired,
)
return
}
err = hdlr.hashcashVal.Validate(
payload.Hashcash, hdlr.params.Config.HashcashBits,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid hashcash stamp: "+err.Error(),
http.StatusPaymentRequired,
)
return
}
}
payload.Nick = strings.TrimSpace(payload.Nick) payload.Nick = strings.TrimSpace(payload.Nick)
if !validNickRe.MatchString(payload.Nick) { if !validNickRe.MatchString(payload.Nick) {

View File

@@ -135,20 +135,22 @@ function LoginScreen({ onLogin }) {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
try { try {
const extraHeaders = {}; let hashcashStamp = "";
if (hashcashBitsRef.current > 0) { if (hashcashBitsRef.current > 0) {
setError("Computing proof-of-work..."); setError("Computing proof-of-work...");
const stamp = await mintHashcash( hashcashStamp = await mintHashcash(
hashcashBitsRef.current, hashcashBitsRef.current,
hashcashResourceRef.current, hashcashResourceRef.current,
); );
extraHeaders["X-Hashcash"] = stamp;
setError(""); setError("");
} }
const reqBody = { nick: nick.trim() };
if (hashcashStamp) {
reqBody.hashcash = hashcashStamp;
}
const res = await api("/session", { const res = await api("/session", {
method: "POST", method: "POST",
body: JSON.stringify({ nick: nick.trim() }), body: JSON.stringify(reqBody),
headers: extraHeaders,
}); });
localStorage.setItem("neoirc_token", res.token); localStorage.setItem("neoirc_token", res.token);
onLogin(res.nick); onLogin(res.nick);