HTTP API design
Local HTTP listener for hub state and admin actions. Plugin-extensible read/write API, designed as the substrate for a future WebUI.
Status: shipped - the #82
HTTP-API arc is complete and on master; the endpoint catalog below is
live. Implementation was phased - see
Β§13 Implementation phases. This document is
the authoritative spec; it is kept in lockstep with the implementation.
- π― 1. Goals + non-goals
- π 2. Transport
- ποΈ 3. Architecture
- π 4. Auth model
- π§© 5. Plugin registration API
- π₯ 6. Request shape
- π€ 7. Response shape
- π§Ύ 8. Audit log
- π§ 9. Discovery endpoint
- π 10. Endpoint catalog
- π§ 11. Out-of-scope of the catalog
- π¦ 12. Dependencies
- πΊοΈ 13. Implementation phases
- β 14. Open questions
- π 15. Related
1. Goals + non-goalsβ
Goals.
- Read + write API over HTTP for hub state and operator commands.
- Plugin-extensible - bundled and third-party plugins register
their own endpoints via a hub-side API. Core only ships the router
- a handful of hub-intrinsic endpoints; everything else is plugin-
owned. If a plugin is not loaded, its path is not registered and
the router answers
404 E_NOT_FOUND("no such endpoint") - there is no code that distinguishes "not configured" from "unknown".
- a handful of hub-intrinsic endpoints; everything else is plugin-
owned. If a plugin is not loaded, its path is not registered and
the router answers
- Designed to underpin a future WebUI (live monitoring + plugin management) - discoverable, versioned, machine-readable error shape.
- Inherits the Phase-8 S3 hardened HTTP framer (
core/iostream.lua: newhttpstage) for transport robustness.
Non-goals.
- HTTPS termination on the API port. The listener is local-only (bind details in Β§2); operators put a reverse proxy in front for non-loopback access.
- Public-facing API. The threat model is "operator + their tools on the same host or behind their reverse proxy" - not anonymous internet traffic.
- Event streams (WebSocket, SSE). May be added in a future phase; not in v3.2.0.
- Service discovery beyond
GET /v1/endpoints(no consul / mDNS).
2. Transportβ
| Property | Value |
|---|---|
| Protocol | HTTP/1.1 (HTTP/1.0 accepted, downgraded to no-keep-alive) |
| Bind | cfg http_bind_addr (default 127.0.0.1). Loopback IS the security premise; only set to 0.0.0.0 / :: in container deployments where the API reaches sibling containers via a private Docker network whose port is NOT published to the host |
| Port | cfg http_port (default false = off). Distinct from ADC ports |
| TLS | Never. Use a reverse proxy if remote access is needed |
| Connection | One request per connection (Connection: close) - rationale in core/iostream.lua framer comment |
| Server header | Never emitted (no version fingerprint pre-auth) |
| Content-Type response | application/json; charset=utf-8 (envelope); except /health which returns text/plain for LB compatibility |
| Content-Type request | application/json; charset=utf-8 for endpoints with a body; rejected with 415 otherwise |
The framer is core/iostream.lua:newhttpstage, extended for phase 1
of this API to permit a Content-Length-bounded body. S3 ships
GET/HEAD-only with hard body-reject; the extension is not a knob
flip - see Β§2.1.
Caps in the framer:
- Request-line: 8192 bytes
- Single header line: 8192 bytes
- Total headers (incl. request-line): 16384 bytes
- Header count: 100
- Request target: 2048 bytes
- Body:
MAXBODY = 65536bytes (writes are operator-shaped, not bulk-upload; cap is generous but bounded) Transfer-Encoding: rejected outright (smuggling defence)- Multiple
Content-Length: rejected (smuggling defence) - OWS before header colon: rejected (smuggling defence)
2.1 Framer body extension (phase 1 substrate change)β
The current S3 framer is a single-shot stage (done = true after
the header parse; subsequent push returns nil and discards
trailing bytes - core/iostream.lua newhttpstage). Phase 1 adds a new
post-header state for collecting body bytes, plus a richer emit
shape. The state machine becomes:
[parsing-headers] ββ headers complete, CL == 0 or absent ββΊ emit{method, target, version, headers, body=""}; done
β²
β²ββ headers complete, CL > 0, CL <= MAXBODY ββΊ [collecting-body]
β²
β²ββ headers complete, CL > MAXBODY ββΊ emit{reject = 413}; done
β²
β²ββ any smuggling-defence trigger (TE, multi-CL, ...) ββΊ emit{reject = 400}; done
[collecting-body] ββ enough bytes received ββΊ emit{method, target, version, headers, body=<CL bytes>}; done
(mid-body socket EOF emits NOTHING: no close-hook, the connection is torn
down and the framer state is GC'd - there is no per-push 413 guard here,
413 fires only on the declared Content-Length during header parse)
[done] any further push ββΊ returns nil, false (trailing bytes discarded)
Concrete contracts:
- Rejection ordering is preserved: smuggling defences (TE, multi-CL, OWS-before-colon, malformed CL value) ALL fire during the header-complete branch BEFORE the body-state transition.
- 413 fires on the CL value, not on byte count. If a client
sends
Content-Length: 1000000, the framer emits413and closes WITHOUT reading any body bytes. Prevents per-request memory amplification. - The router gets the 413 (and 400) reject units the same way it
gets parsed-request units today - via the existing
{reject = <status>}shape. No new router-side code path for rejection. bodyfield on success is a Lua string of EXACTLYContent-Lengthbytes (or""for no-body requests). The router JSON-parses it; the framer is content-agnostic.- HEAD requests: body MUST be absent (per RFC). If a HEAD
arrives with
Content-Length > 0, framer emits400. - Connection close mid-body: when the underlying socket closes
before
Content-Lengthbytes have been collected, the framer stays in[collecting-body]with no emitted unit. server.lua's read loop tears down the handler on EOF, the framer state is GC'd. No response is sent - the client crashed first, a partial-write would have nowhere to land. Matches RFC 7230 Β§3.4 (implicit recovery). The stage has no close-hook today; if a future use-case demands an explicit 400 on mid-body EOF, add aflush_on_close()method to the stage contract then.
Unit-test coverage required for phase-1 framer extension:
- POST with
Content-Length: 0and no body β success unit, empty body - POST with
Content-Length: Nand exactly N body bytes β success - POST with
Content-Length: Narriving in 1, 2, N TCP segments β success - POST with
Content-Length: MAXBODY(exact boundary) β success - POST with
Content-Length: MAXBODY+1β 413, no body read - POST with
Transfer-Encoding: chunkedβ 400 (smuggling defence) - POST with
Content-Length: 0\r\nContent-Length: 5\r\nβ 400 (multi-CL) - POST with lowercase method (
post) β 400 (request-line regex anchors^(%u+)) - POST with NUL bytes in body β success, body byte-exact
- POST with body containing
\r\n\r\nβ success, body byte-exact (must not mis-parse as header end) - GET with
Content-Length: 5β 400 (no GET endpoint accepts a body) - HEAD with
Content-Length: 5β 400 - HEAD with
Content-Length: 0β success (HEAD)
3. Architectureβ
/v1/...
client ββΊ iostream.newhttpstage ββΊ core/http.lua router ββ¬ββΊ core handler
β² (framer + caps) β β
β β βββΊ plugin handler
β β (registered via hub.http_register)
βββββββββ envelope response ββββββββββββ
+ audit log
+ rate-limit decision
core/http.lua owns:
- Request dispatch (method + path β registered handler)
- Auth check (token resolve β scope check)
- Rate-limit decision (per-token bucket)
- Idempotency-key cache (per-token TTL map)
- Audit log emission (every write goes to
api_audit.log) - Envelope formatting (success + error)
- JSON encode/decode (via bundled
dkjson) - Discovery endpoint
GET /v1/endpoints
Plugins (and core itself for the few hub-intrinsic endpoints) register handlers via the Plugin Registration API. Handlers receive a parsed request struct and return a result struct; the core router does everything else.
4. Auth modelβ
Token-based bearer auth with three scopes: none, read, and
admin. none marks routes that skip the router's bearer gate -
unauthenticated routes like /health and plugin-self-auth routes
like the webhook receiver (the handler does its own auth); read
and admin are token-gated. Β§4.4 details each.
4.1 cfg shapeβ
http_api_tokens = {
["dashboard-readonly-7f3..."] = { scope = "read", comment = "grafana scraper" },
["operator-3kd2..."] = { scope = "admin", comment = "ops cli" },
}
- Map key = token (opaque string, operator-generated). Any non-empty
string works syntactically; recommended β₯32 bytes from
/dev/urandombase64-encoded for adequate entropy. scope: required, exactly one of"read"or"admin". A malformed entry (bad scope or wrong shape) is dropped per-entry at startup: only the offending entry is removed, the remaining valid tokens stay active, and a warning is logged. (An earlier version invalidated the WHOLE table on a single bad entry and left the listener unbound; that is fixed.)comment: optional, free-form; surfaced inapi_audit.logfor attribution.- Empty table or missing key = API is reachable but answers
401for everything except/health. The first-boot bootstrap (Β§4.7) ensures a freshly-installed hub still has a usable admin token.
4.2 Token lifecycleβ
- Read at hub startup (
loadsettings). - Re-read on
+reload(operator changed cfg, intent is to pick up the change). Tokens that were removed in cfg invalidate immediately; tokens that were added become active. Tokens with no change persist. - Tokens never rotate without explicit operator action. External apps only need to update their token when the operator rotates it.
4.3 Token transportβ
- Header
Authorization: Bearer <token>(RFC 6750). - No query-string fallback (tokens in URLs leak into proxy logs).
- Constant-time comparison required: Lua's
==on strings short- circuits at first mismatch byte, leaking length and prefix-match timing. The hub shipsadclib.constant_time_eq( a, b )in C (XOR-accumulate over equal-length strings, single branch on the final accumulator); it is the active path wheneveradclibis loaded - the normal case. A pure-Lua fallback (same algorithm with Lua 5.4 native bitwise~/|/string.byte) runs ONLY in stripped builds withoutadclib. Both are timing-leak-free at the Lua level; our hub uses the plain Lua 5.4 interpreter, not LuaJIT, so the constant-time property holds.
4.4 Scope semanticsβ
| Scope | Can | Cannot |
|---|---|---|
none | Reached without a bearer token; the route is part of the route table and listed by /v1/endpoints. Used by /health and by plugins that do their OWN authentication (e.g. etc_webhook's HMAC-signed webhook routes - the handler verifies the signature over req.raw_body). | n/a (no router auth gate; the handler authenticates) |
read | GET endpoints + GET /v1/endpoints filtered to {none, read} routes | Any non-GET; admin-scoped GETs (these DO ship - e.g. GET /v1/log/api, /v1/log/error, /v1/log/cmd, /v1/log/audit) |
admin | Everything read + all writes (POST/PUT/PATCH/DELETE) + admin-scoped GETs | n/a |
The dispatcher checks scope BEFORE invoking the handler. A scope
mismatch returns 403 E_FORBIDDEN. Auth itself is skipped for
scope = "none" routes; the route still goes through the regular
route-lookup + 405 + OPTIONS machinery.
4.5 /healthβ
Unversioned, registered with scope = "none" (unauthenticated),
public on the loopback port. Returns 200 text/plain "ok\n".
Purpose: load-balancer / supervisor health probe (cfg-management
ops should not have to ship a token to systemd or similar). Carries
no hub state. Listed in /v1/endpoints like every other registered
route; the implementation routes it through the regular dispatch
pipeline (the scope = "none" flag is what makes it
unauthenticated, not a special case in the router).
4.6 X-Confirm: yes for destructive endpointsβ
A small handful of endpoints have outsized blast radius if hit accidentally (shell history misfire, IDE autocompletion of a wrong URL). Those require the client to set the header
X-Confirm: yes
in addition to the bearer token. Missing or wrong value returns
400 E_CONFIRMATION_REQUIRED. A WebUI sets the header automatically
when the operator clicks the confirm dialog; a CLI tool spells it
out (the muscle-memory step the guardrail is meant to add).
Endpoints with X-Confirm: yes required:
POST /v1/reloadPOST /v1/restartPOST /v1/shutdownDELETE /v1/registered/{nick}DELETE /v1/usercleaner/expiredDELETE /v1/usercleaner/ghostsDELETE /v1/usercleaner/orphan-comments
Not required for DELETE /v1/users/{sid} (kick) or other write
endpoints - those are common, low-impact, and the audit log is
sufficient.
The router enforces this list at core/http_router.lua
_xconfirm_required; the Β§10 catalog footnotes also flag each
endpoint individually.
4.7 First-boot token sample (no auto-activation, #231)β
The HTTP API is opt-in on BOTH http_port AND http_api_tokens.
Setting one without the other does NOT bind the listener; the
operator must explicitly populate both for the API to come up.
If http_port is set but cfg.tbl http_api_tokens is empty or
absent at startup, the hub generates a securely-random admin-scoped
sample token, writes it to cfg/api_token.first (chmod 600,
owner-only) as a convenience for the operator to copy, and logs:
hub.lua: http_port is set but cfg.tbl http_api_tokens is empty; wrote sample token to cfg/api_token.first (chmod 600). Copy it into cfg.tbl and restart (or +reload) to activate the HTTP API. Listener was NOT bound.
(Logged via out.error with the standard hub.lua: ... prefix.)
The sample token is NOT activated in-memory. It is purely
documentation: a securely-generated value that the operator may
copy into cfg.tbl http_api_tokens (or ignore in favour of
generating their own via e.g. openssl rand -base64 32). The HTTP
listener will not bind until cfg.tbl carries at least one token.
Activation flow:
- Operator sets
http_port = 5005andhttp_api_tokens = { }(or omits the key entirely) incfg.tbl, restarts the hub. - Hub writes
cfg/api_token.firstand logs the warning above. HTTP listener does NOT bind. ADC listeners are unaffected. - Operator copies the token from
cfg/api_token.firstintocfg.tbl http_api_tokens, restarts the hub (or, on a later boot where the listener IS bound, just+reload). - HTTP listener binds on
http_port. API is now reachable. - Operator deletes
cfg/api_token.first.
Why no in-memory activation (history): earlier drafts of this
spec activated the sample token in-memory via cfg.set(..., nosave = true). This made the API "just work" on first boot but
introduced a footgun: +reload reads cfg.tbl fresh and silently
wipes the in-memory token, locking the operator out until a full
process restart (which then generates a NEW token, overwriting
api_token.first). Issue #231 removed the in-memory activation;
cfg.tbl is now the single source of truth for API tokens.
Re-running with empty tokens. If the operator removes all tokens
from cfg.tbl and triggers +reload while the listener is already
bound, the listener stays bound but every request returns 401.
The operator recovers by restoring tokens in cfg.tbl + another
+reload. The "sample token" path only runs at hub start, not
during +reload.
Ordering on boot: the sample-token file is written BEFORE any HTTP listener bind attempt. If the file write fails (EACCES, filesystem full) the hub logs the error and does NOT bind the listener. ADC listeners are unaffected.
4.8 Failed-auth rate-limitβ
The per-token rate-limit (Β§6.3) only attributes to known tokens. A
brute-forcer hitting random tokens gets 401 but no token-bucket
attribution. To bound that traffic without locking out the WebUI
that happens to share the loopback IP with a misbehaving client:
- Per-prefix failed-auth bucket. When an
Authorization: Bearer <X>header is present and resolves to no known token, the per-prefix bucket is consumed. The bucket is keyed on the first 4 chars of<X>(length-leak limited; not the full token because we don't want to log it). Default 10 failed-auths / minute / prefix, burst 5. Cfg keyshttp_api_authfail_prefix_rate/_burst. Bucket exhaustion returns429 E_RATE_LIMITEDwithRetry-After: 60. Anonymous probes (noAuthorizationheader) and malformed headers do NOT consume the prefix bucket - they fall straight into 401. - Per-connection counter is moot under the current transport.
The spec originally called for a per-TCP-connection counter
(
MAX_FAILED_AUTHS_PER_CONN = 3) as the first line of defence. The HTTP listener currently issuesConnection: closeon every response (one HTTP request per TCP connection), which makes the per-connection counter equivalent to a 1-strike rule before TCP teardown. The per-prefix bucket already carries the abuse- defence load on its own; the per-connection counter would only add value if we ever introduced HTTP keep-alive, at which point it can be revisited. - Reverse-proxy-aware: if the listener is reached via a reverse
proxy (operator deployment), the proxy SHOULD set
X-Forwarded-For. Loopback proxy β useX-Forwarded-Forvalue to augment the prefix bucket. Without trusted X-F-F, accounting stays at the prefix level. The reverse-proxy X-F-F augmentation is not implemented in Phase 1c (no proxy in the loopback-only default deployment); it is reserved for the Phase 2+ WebUI work. - Loopback hits are NOT exempt: the prefix bucket fires even when
the connecting peer is
127.0.0.1.
5. Plugin registration APIβ
Plugins register endpoints by calling a hub-provided global from
inside an onStart listener.
hub.http_register( method, path, scope, handler, meta )
5.1 Argumentsβ
| Arg | Type | Meaning |
|---|---|---|
method | string | "GET" / "POST" / "PUT" / "PATCH" / "DELETE" |
path | string | URL path including version prefix, e.g. "/v1/bans". Path variables in {name} form, e.g. "/v1/bans/{id}" |
scope | string | "read", "admin", or "none". "none" skips the router's bearer-token gate - use ONLY when the endpoint does its OWN authentication (e.g. an HMAC-signed webhook receiver, etc_webhook); see the Β§4.4 scope table |
handler | function | function(req) -> result (see Β§6 + Β§7) |
meta | table or nil | Optional metadata - {plugin=, description=, request_schema=, response_schema=, audit_redact_body=}. plugin is the source plugin name, for /v1/endpoints + /v1/plugins attribution. Surfaced via /v1/endpoints (except audit_redact_body, which is router-internal). Used by WebUI for form rendering. audit_redact_body = true opts the route into Β§8 audit-body redaction (used by password endpoints) |
5.1.1 Higher-level helper: util_http.http_register_user_actionβ
For the common "user action by SID" pattern (kick / redirect /
gag / etc β an admin endpoint that operates on one online user
identified by {sid} in the path), prefer the helper in
core/util_http.lua:
util_http.http_register_user_action(
scriptname, -- plugin name (for /v1/endpoints discovery)
method, -- "POST" / "DELETE" / ...
path, -- "/v1/users/{sid}" or "/v1/users/{sid}/<action>"
action_verb, -- "disconnect" / "redirect" / ... (static literal)
handler_fn, -- function(req, target) -> data | (nil, err)
meta -- optional, same shape as hub.http_register's meta
)
The helper:
- Verifies
{sid}is present, the SID is online, and the user is not a bot β returns 400 / 404 / 409 with the standard error codes on failure. The plugin handler never sees those cases. - Constructs the Β§7.1.1 response envelope (
{action, sid, nick, ...handler_fields}); the plugin handler returns just the action-specific fields (e.g.{reason="flood"}or{url="adc://..."}). - Is fail-soft: returns
falseifhub.http_registeris absent (stripped builds without the HTTP API framework still load the plugin's ADC chat-cmd surface unchanged). - Hard-codes scope =
"admin"β user-action endpoints are always admin by definition. Read-only or per-user-self surfaces usehub.http_registerdirectly with their own scope.
When to use the lower-level hub.http_register instead:
- Read endpoints (
GET) that need scope"read". - Resource endpoints with non-SID target keys (e.g.
cmd_banwith nick / cid / ip targets β Phase 2 PR-4). - Endpoints with a different response envelope shape (none in
Phase 2;
/healthand/v1/endpointsin Phase 1).
Convention: who fires report.send? Within the
handler_fn(req, target) body, the plugin owns the opchat-report
firing. Both styles are valid; pick the one that matches the
plugin's existing ADC code path so the ADC-vs-HTTP behaviour stays
symmetric:
- Caller-invoked report (PR-1
cmd_disconnect, PR-2cmd_redirect): the shareddo_<verb>()helper returns the formatted report message; both the ADConbmsgpath and the HTTP handler callreport.sendthemselves at the right moment. Needed when the ADC path interleaves a chat-echo to the operator between the kick and the report. - Helper-internal report (PR-3
cmd_gag): the existingadd_user/remove_userhelpers already callreport.sendinline; the HTTP handler just invokes them and returns. Simpler but harder to override the report timing.
5.2 Registration lifecycleβ
- Plugin calls
hub.http_registerfrom inside itsonStartlistener. +reloadclears the entire route table BEFORE re-running plugin init. Plugins re-register their routes on the newonStartcycle.- Conflict (two plugins claim the same method + path) raises an error
at registration time and the second plugin's
onStartreturns false. Operator sees a startup error inerror.log; hub continues with the first registration. - Registration is single-shot per plugin per route: a duplicate
method + path always raises "duplicate route" at registration
time, regardless of handler identity. There is no same-handler
no-op - a second
registerfor an already-claimed method + path is an error.
5.3 Naming conventionβ
- Bundled plugins use the unprefixed
/v1/<resource>form, e.g.cmd_banβ/v1/bans. - Third-party plugins SHOULD use
/v1/x/<plugin-id>/...to avoid clashing with future bundled plugins. The router does not enforce this - it is convention.
5.4 Handler contractβ
local handler = function( req )
-- req = parsed request struct (Β§6)
-- return either a success result or an error result (Β§7)
return { status = 200, data = { ... } }
-- or: return { status = 400, error = { code = "E_BAD_INPUT", message = "..." } }
end
The handler MUST be pure-Lua; it MUST NOT block on I/O (the hub's
event loop is single-threaded). It SHOULD return an error result
table for expected error cases (clearer trace, machine-readable code)
rather than raising. The router wraps every handler call in pcall
as a defence-in-depth - an uncaught error becomes
500 E_INTERNAL and is logged to error.log with the traceback.
A handler that uses errors for control flow works but is harder to
debug.
5.5 Router-side schema validation (optional)β
meta.request_schema may declare a minimal type + required spec.
The router validates the parsed req.body against it BEFORE the
handler is invoked. Failure returns 400 E_BAD_INPUT with a
message naming the offending field. Reduces boilerplate in every
handler.
hub.http_register( "POST", "/v1/bans", "admin", ban_handler, {
description = "create a ban",
request_schema = {
target_type = { type = "string", required = true, enum = { "nick", "cid", "ip" } },
target = { type = "string", required = true, max_length = 64 },
duration_minutes = { type = "integer", required = false, min = 1, max = 525600 },
permanent = { type = "boolean", required = false },
reason = { type = "string", required = false, max_length = 256 },
},
response_schema = {
id = { type = "string", required = true },
},
} )
Supported field-spec keys: type ("string" / "integer" /
"number" / "boolean" / "object" / "array"), required,
enum, min / max (numbers), min_length / max_length
(strings), pattern (Lua pattern - NOT PCRE; %d is digit, .
matches any char, no \d / \w; WebUI builders MUST be told this).
For type = "array" and "object" the router validates ONLY that
the value is a table and is present - it does NOT distinguish array
from object (both collapse to a type == "table" check), so
type = "array" accepts an object and vice-versa. Array-vs-object,
plus nested item or property validation, is the handler's job.
Rationale: phase 1
endpoints (see catalog Β§10) all have flat request bodies; a full
recursive validator is bloat we don't pay for until a real
nested-body endpoint shows up. The schema mini-spec is
intentionally constrained.
If a future endpoint genuinely needs nested validation, options:
(a) extend the mini-spec with items + properties (~20 LoC), or
(b) keep flat schemas and have the handler validate the nested
shape inline. Decide at that endpoint's design time, not pre-
emptively here.
Handlers MAY skip the schema and validate inside themselves; that is fine when the validation is dynamic (e.g. depends on a runtime table). For static shapes the schema is the canonical and shorter way.
response_schema is documentation only. It surfaces via
GET /v1/endpoints so the WebUI can pre-build forms / table
columns, but the router does NOT validate the handler's actual
response against it. The handler is trusted to keep them in sync;
diverging schema vs response is a bug to find in code review, not
at runtime.
6. Request shapeβ
The router parses the framer's parsed-request unit into a req
struct passed to the handler:
req = {
method = "POST", -- uppercase
path = "/v1/bans", -- with version prefix
path_vars = { id = "abc" }, -- {} if no {name} segments
query = { lines = "100" }, -- query-string parsed; values are RAW URL-encoded strings
-- (the router does NOT %-decode; handlers do so per-endpoint)
headers = { ["content-type"] = "..." },-- lowercased keys
body = { reason = "spam" }, -- nil for no-body methods or empty body;
-- parsed JSON object for endpoints with a body
raw_body = "{ \"reason\": \"spam\" }", -- original string, for handlers that want it
token_label = "ops cli (operatoβ¦3kd2)", -- non-secret label for logs:
-- "comment (first4β¦last4)". Handlers MUST
-- log this, never the cfg key itself.
token_scope = "admin",
source_ip = "127.0.0.1", -- for audit log
idempotency_key = nil, -- string if client sent X-Idempotency-Key
request_id = "01HKE7...", -- client-sent X-Request-ID, or an auto-generated
-- UUIDv4-SHAPED id (NOT a real UUIDv4 - see Β§6.5)
confirm = false, -- true iff client sent X-Confirm: yes
actor = "alice", -- client-sent X-Actor (Β§6.7), sanitised; nil if
-- absent. Audit-only correlation hint, NOT authz.
}
6.1 JSON parsingβ
- Body is parsed with
dkjsononce by the router; failure returns400 E_BAD_JSONto the client and the handler is not invoked. - Top-level MUST be a JSON object (not array, not bare value). Arrays go in fields of the object.
6.2 Idempotency-keyβ
- Header
X-Idempotency-Key: <opaque-string>(recommended UUID). - Per-token cache mapping
(token_bucket, method, path, key) β (status, body, headers)with a 5-minute TTL. The cache key includes method + path-template so a client that reuses the sameX-Idempotency-Keyacross two different write endpoints (e.g. a shared request-correlation id) does NOT get the first action's cached reply replayed for the second - each route has its own slot. - Cache hit β router returns the cached response immediately, handler
is not invoked, audit log NOT re-emitted (the original write
was already logged; an idempotent retry must not double-log).
The current request's
X-Request-IDis overlaid on the replay so the client can correlate its log line with this turn rather than the original. - Cache miss β handler runs, the response is stored before being returned, audit log emits once.
- Applies only to write methods (POST/PUT/PATCH/DELETE). GET / HEAD responses are not cached. Errors (4xx/5xx) ARE cached: a retry of a deterministically-failing request gets the same response, not a re-execution that might race differently.
- Bounded size. Cfg
http_api_idempotency_max_entries(default 1024). When the cap is hit, oldest entry by insertion time is evicted (FIFO, not LRU - keeps the data structure trivial; the cache is bounded by both 5-min TTL and entry-count, so eviction strategy precision matters little). - Cache is cleared on
+reload. The route table clear (Β§5.2) invalidates the handler closures the cached responses were produced by - keeping the cache across reload could surface a response whose code path no longer exists. A write retry that spans a+reloadmay therefore double-execute; that is the intended trade-off (operator-initiated reloads are rare, retries spanning one are rarer, double-execution is recoverable while a stale cache hit is silent and confusing). - Deferred-response endpoints are NOT idempotency-cached. The
long-poll path (
GET /v1/events?wait=...) uses the deferred- dispatch sentinel mechanism described in Β§10.1; the router returns fromdispatch()before the response bytes exist, so no(status, body, headers)tuple is available to store. Today the only deferred endpoint is GET (idempotency doesn't apply to GET anyway); a hypothetical future deferred write endpoint would need its own at-rest dedup story. Spec note added per #275 holistic review.
6.3 Rate-limitβ
- Token-bucket per
token_label. Defaults:readscope 120/min,adminscope 60/min, burst 10 (shared across scopes). Cfg- tunable per scope (http_api_rate_read,http_api_rate_admin) and burst (http_api_burst). Read default is doubled because the WebUI polls. - Exceeded β
429 E_RATE_LIMITEDwithRetry-After: <seconds>header. - Buckets share
core/ratelimit.luainfrastructure with the ADC side. Per-token buckets are keyed on an internalbucket_id(first 8 + last 8 chars of the token = 16 chars, or the whole token when it is shorter than 16); no comment is included. The full token never enters the bucket map, so the rate-limit state cannot leak secrets even if dumped. - The failed-auth bucket (Β§4.8) is checked BEFORE the token bucket: an attacker grinding tokens hits the failed-auth defences first.
/healthis NOT rate-limited (probes are noisy by design; scope=none routes bypass auth and therefore have no token to attribute the bucket to).- Scope=none routes (
/health) bypass rate-limit entirely as a consequence of bypassing auth. X-Confirm endpoints (the full Β§4.6 list -/v1/reload,/v1/restart,/v1/shutdown,DELETE /v1/registered/{nick},DELETE /v1/usercleaner/expired,DELETE /v1/usercleaner/ghosts,DELETE /v1/usercleaner/orphan-comments) are exempt from the per-token bucket budget (Β§4.6): an operator's recovery action must succeed even if a runaway script just burned the admin token's budget. The X-Confirm header is the abuse-protection guard for these endpoints (forces human intent); the audit log is the forensic trail. - 403 / X-Confirm-missing responses do not consume bucket budget. The rate-limit gate runs after the scope check + the X-Confirm carve-out lookup, so a token that lacks scope (403) or fails the X-Confirm check (400) does not pay the bucket cost.
6.4 Paginationβ
GET /v1/users and GET /v1/registered may return large lists and
support pagination:
GET /v1/users?limit=100&offset=0
limitdefault 200, max 1000. Values outside the range are clamped (not rejected) - clients that ask forlimit=999999get 1000, which is friendlier than 400.offsetdefault 0.- Response carries a
paginationsibling ofdata:
{
"ok": true,
"data": { "users": [...] },
"pagination": { "total": 4231, "limit": 100, "offset": 0, "next_offset": 100 }
}
next_offsetis OMITTED (the JSON key is absent, notnull) on the last page - dkjson does not serialise a nil field. Clients MUST treat a MISSINGnext_offsetas "no more pages" rather than testing fornull.
Filtering and sorting (#264)β
Phase 1 reserved the convention; #264 lands the concrete contract.
Per-endpoint allowlist - each list endpoint declares its
searchable + sortable fields; unknown filter or sort fields return
400 E_BAD_INPUT with the allowed-fields list in the error message.
Field types and semantics:
| Type | Convention | Example |
|---|---|---|
| String | substring match, case-sensitive (string.find plain=true) | ?nick=ali matches alice, alibaba |
| Integer | exact ?field=N AND optional ?field_min=N / ?field_max=N range | ?level=20, ?level_min=20&level_max=50 |
| Boolean | ?field=true / ?field=false | ?is_online=true |
| Date | ?field_after=... / ?field_before=... paired params | ?regged_at_after=2026-01-01 / 00:00:00 |
Sort: ?sort=field ascending, ?sort=-field descending. Single
sort key only. Default sort is per-endpoint (see each endpoint's
footnote).
Filter applies BEFORE pagination. pagination.total reflects
the filtered count, NOT the unfiltered hub total.
PR-A landed /v1/users and /v1/registered. PR-B landed
/v1/bans, /v1/blacklist, /v1/msgmanager,
/v1/trafficmanager/blocks, and /v1/usercleaner/expired+ghosts.
Query-string values are NOT URL-decoded by the router (#275
CON-3 note). core/http_router.lua parse_query strips the ?
prefix and splits on & / =, returning raw URL-encoded values
to handlers. A filter like ?nick=ali%20ce will look for the
literal substring ali%20ce in the stored nick - NOT ali ce.
Clients should send filter values in their unencoded form (i.e.
let the HTTP library use the raw query string; do not pre-encode).
A future router-side decode pass is an open follow-up; until then
the contract is "raw bytes, no decode".
/v1/bans/history is not in scope - its response is a
dict-keyed-by-nick rather than a flat array, so the helper's
filter/sort/paginate flow does not map cleanly; the pre-existing
?nick= param remains. Tracked as a separate future enhancement
if structured filter on its entries is needed.
Tail-style endpoints (logs, chatlog) use ?lines=N instead of
limit/offset. Different shape because they return a contiguous
window from the end of the resource, not paginated random-access
through it. Cap max_lines = 1000; clamping rules follow Β§6.4.
6.5 X-Request-IDβ
- If the client sends
X-Request-ID: <opaque>, the router echoes it in the response header and in the audit log. - If the client does NOT send one, the router generates a
UUIDv4-shaped id (8-4-4-4-12 hex with the version nibble pinned to
4) and echoes it back. It is NOT a real UUIDv4 - the variant nibble
is unconstrained and
math.randomis not a CSPRNG - so it is a log-correlation handle, not a cryptographic uniqueness guarantee. The client can then correlate its log line with the audit log entry without having to invent IDs.
6.6 OPTIONS + HEAD auto-supportβ
- For any registered
GETroute,HEADautomatically works - responds with the same headers asGETand an empty body. Status is the would-be GET status. Handlers do NOT receive HEAD; the router runs the GET handler, serializes the JSON envelope to measure its length, setsContent-Lengthto that exact value, then discards the body before writing. This is the RFC-conformant answer; the cost (a discarded serialization) is acceptable for the very rare HEAD on an admin API.- GET handler side-effect contract. Because HEAD invokes the same handler as GET, GET handlers MUST be idempotent / side- effect-free. A counter increment or a state mutation inside a GET handler would fire on HEAD probes too. Plugin authors: write changes in POST/PUT/PATCH/DELETE handlers only.
OPTIONS <path>on a registered path returns the allowed methods for that path in theAllowheader. Body is empty, status 204. No auth required (this is introspection, not data). HEAD is implicitly listed alongside any registered GET; OPTIONS itself is always listed. OPTIONS on an UNKNOWN path falls into the normal unknown-path handling (401 anonymous, 404 authed).- Method mismatch (path registered for POST but client sends GET)
returns
405 E_METHOD_NOT_ALLOWEDwithAllow: POSTheader. Distinct from404 E_NOT_FOUNDwhich means "path not registered at all". Anonymous callers do NOT see 405 - they get 401 first (no path-existence leak to unauthenticated callers).
6.7 X-Actor (audit attribution)β
Some deployments front the API with a service that holds ONE hub token and acts on behalf of many operators - the WebUI BFF (#82) is the reference case: it authenticates the operator itself, then calls the hub with its single admin token. Without extra information the audit log would attribute every such call to that one token, losing "who actually did this".
- If the caller sends
X-Actor: <nick>, the router records it in the audit log as anactor=field (Β§8), alongside the authenticatedtoken=field. The token remains the authenticated principal; the actor is the caller's CLAIM about who is behind the call. - X-Actor is never an authorization input. It does not affect
routing, scope, rate-limit buckets, or any handler decision - it is
purely an audit-correlation hint. A token holder can set it to any
string, so treat
actor=as "the token holder asserted this nick", not as a verified identity. The trust boundary is the token; the actor is only as trustworthy as whoever holds it (for the WebUI, the BFF, which verified the operator viaPOST /v1/auth/verify). - The value is sanitised exactly like any attacker-supplied header:
control bytes, whitespace, and
=all become?(so the value can never introduce an audit-line field boundary - e.g. forge asrc=ahead of the real one), then it is capped at 64 chars (the auth-verify nick contract). A reguser nick that legitimately contains a space therefore appears asfoo?barin the hint;token=remains the authoritative identity. Absent/empty renders as-. - It is recorded ONLY when the request carried a valid bearer token
(an authenticated principal). Every request without one renders
actor=-: the rejected probes (401 / 404 / 405 / 429-prefix) AND anonymous calls toscope="none"routes such as the webhook receiver. So an anonymous caller can never put a chosenactor=string in the file - the authenticatedtoken=is the gate.
7. Response shapeβ
All responses except /health use a JSON envelope.
7.1 Successβ
{
"ok": true,
"data": { ... }
}
HTTP status carries the high-level outcome (200 / 201 / 204) and the
data field carries the payload. For 204 No Content, data is null
and the body may be empty.
7.1.1 Write-endpoint response convention (Phase 2 lock-in, #200)β
Write endpoints (POST / PUT / PATCH / DELETE that mutate hub state)
follow a uniform data shape so a generic admin client can dispatch
on a single field rather than switching on N per-endpoint boolean
verb flags:
{
"ok": true,
"data": {
"action": "<verb>",
"sid": "<sid>",
"nick": "<nick>",
... // action-specific fields, e.g. `reason`, `url`, `gag_duration_minutes`
}
}
actionis a short kebab-case (or single-word) verb identifying the operation that just happened. Stable across the API: a client can mapactionto a handler table.sid+nickidentify the target where applicable. Endpoints that don't operate on a single online user (e.g./v1/announce,/v1/topic) omit them.- Action-specific fields (
reason,url,duration_minutes, ...) sit flat alongside, NOT nested under aparamsblock. The flat shape was chosen over{action, target, params, result}because client code readsdata.urlmore naturally thandata.params.url, and the extra nesting costs bytes on the wire without a corresponding payoff for the read case. - Verb-boolean fields (
disconnected: true,redirected: true) are NOT used. The early Phase-2 PRs (#199, #201) shipped that shape; #200 is the tracker that normalised on the current convention.
Read endpoints (GET) MAY use any shape under data they like
(e.g. /v1/users carries a pagination sibling, /v1/version
carries flat fields directly). The action-verb convention is
specifically for state-mutating endpoints; reads don't perform an
action.
7.2 Errorβ
{
"ok": false,
"error": {
"code": "E_NOT_FOUND",
"message": "user with sid 'ABCD' not found"
}
}
HTTP status mirrors the broad error class (400 / 401 / 403 / 404 /
409 / 429 / 500); the error.code is the precise machine-readable
discriminator. WebUI / clients pattern-match on code, surface
message to humans.
7.3 Reserved error codesβ
| Code | HTTP | Meaning |
|---|---|---|
E_BAD_JSON | 400 | Body is not valid JSON or not an object |
E_BAD_INPUT | 400 | Body parsed but a field is missing / wrong type / fails schema |
E_CONFIRMATION_REQUIRED | 400 | Endpoint requires X-Confirm: yes header (Β§4.6) |
E_UNAUTHENTICATED | 401 | No / bad bearer token |
E_FORBIDDEN | 403 | Token scope insufficient for endpoint |
E_NOT_FOUND | 404 | Resource does not exist (e.g. sid not online), or the path is not registered at all ("no such endpoint" - e.g. the plugin that would own it is not loaded) |
E_METHOD_NOT_ALLOWED | 405 | Method not implemented for path; Allow header lists what is |
E_CONFLICT | 409 | State conflict (e.g. user already banned) |
E_PAYLOAD_TOO_LARGE | 413 | Reserved only. A 413 is emitted by the framer as transport-level text/plain ("413 Payload Too Large") with NO JSON envelope, so it carries no machine-readable error.code - unlike every routed error above |
E_UNSUPPORTED_MEDIA_TYPE | 415 | Request body present but Content-Type is not JSON |
E_RATE_LIMITED | 429 | Token bucket or failed-auth bucket empty; check Retry-After |
E_INTERNAL | 500 | Handler raised; details in error.log only |
Plugins MAY define their own error codes following the E_* prefix
convention. They SHOULD document them in their meta.response_schema.
7.4 Timestamp + ID conventionsβ
- Timestamps: ISO 8601 UTC, second precision, trailing
Z:"2026-05-21T19:32:11Z". Never epoch seconds. Generated via Luaos.date("!%Y-%m-%dT%H:%M:%SZ", t). - Durations: integer seconds in field name
_seconds(e.g.uptime_seconds,connect_time_seconds_ago) or integer minutes in_minutesfor human-input-sized things (ban duration). Never both for the same concept. - IDs: opaque strings unless explicitly noted. SIDs are 4-char Base32 (ADC native). Ban IDs are server-assigned UUIDv4. User nicks are the natural primary key for the registered-users resource.
7.5 CORS - explicitly not handledβ
The hub does NOT emit CORS headers. The listener is loopback-only;
non-loopback access goes through a reverse proxy where the operator
handles CORS (and TLS, and IP allowlisting, and request logging) at
that layer. If a future WebUI is hosted on a separate origin from
the API, that origin's reverse proxy adds the Access-Control-Allow- Origin headers - not the hub.
Same-origin WebUI (served by the hub on the same port - future phase, not now) would not need CORS at all.
8. Audit logβ
Dedicated api_audit.log in the standard log path, written through
the existing out.createlog infrastructure for consistency with
event.log / error.log / script.log:
-- in core/out.lua's return table:
api_audit = createlog( "api_audit", "api_audit.log", "log_api_audit" )
New cfg key log_api_audit (default true) gates the stream;
operators can disable via cfg + reload. One line per non-GET request:
[2026-05-22 | 14:32:11] POST /v1/bans 200 token=ops cli (oper...3kd2) actor=alice src=127.0.0.1 idem=- req_id=8f14a2c0-1b3d-4e5a-9c7f-2a6b1d0e4f88 body={"target_type":"nick","target":"baduser","duration_minutes":60}
- Token field shows first + last 4 chars (full token never in logs).
commentfield from cfg (when set) appears BEFORE the parens; the parens hold thefirst4...last4token fragment. Anactor=field always sits betweentoken=andsrc=; areq_id=field always sits betweenidem=andbody=.actor=is the client-asserted operator nick fromX-Actor(Β§6.7), a correlation hint for services that front the API with one token (the WebUI BFF). It is NEVER an authorization input - thetoken=field remains the authenticated principal. It is-whenever there is no authenticated bearer principal (rejected probes AND anonymousscope="none"calls) or when noX-Actorheader was sent, so an anonymous caller cannot inject a chosen actor.- Body is JSON-serialised, max 512 bytes (truncated to 509 plus
...if longer), control-bytes replaced with?(seecore/http_router.lua:logsafe_body). The 512-byte truncation is a log- injection defence, NOT a secret-redaction primitive. A 256-byte token or a 30-byte password both fit well under the cap and land on disk verbatim unless the route opts into the redact mechanism below. - Failure responses are logged with the resolved HTTP status.
Per-route body redaction. A route declares audit_redact_body = true in its hub.http_register(...) meta when its body contains
secrets (passwords, tokens, paths to keys). The router replaces the
body=... field with body=[redacted] for that route. Diagnostics
still get method + path + status + token + idempotency-key +
request-id, which is enough to correlate with the request without
storing the secret. Currently used by:
PUT /v1/registered/{nick}/password(entire body is the new password)POST /v1/registered(optionalpasswordfield in body)
Redaction is whole-body, not per-field: an operator inspecting the
audit log for a redacted route sees body=[redacted] even for
non-sensitive sibling fields (nick / level / comment on
POST /v1/registered). The values are still recoverable from the
resource state (GET /v1/registered/{nick}) - the audit log is the
correlation channel (who did what, when), not a structured query
surface. If a future endpoint has only one sensitive field among
many useful diagnostics, a per-field redact primitive can be added
without changing the existing whole-body flag.
Unauthenticated / unmatched requests (401, 404, 405,
429-prefix) log body=[skipped]. The bytes are attacker-chosen
and have no diagnostic value (no route resolution happened, so the
per-route redact policy is unknown); landing them on disk would
give an unauthenticated caller an insider-exfil channel via
/v1/log/api. The method + path + source-ip + token-prefix are
still logged so brute-force attempts remain visible to operators.
GET requests are NOT logged in api_audit.log (would be noisy under
WebUI polling). They can be enabled by cfg flag
http_api_log_reads = true for forensic sessions.
9. Discovery endpointβ
GET /v1/endpoints (scope read) returns the live route registry,
scope-filtered to what the calling token can reach:
{
"ok": true,
"data": {
"endpoints": [
{
"method": "GET",
"path": "/v1/users",
"scope": "read",
"plugin": "core",
"description": "list online users",
"request_schema": null,
"response_schema": { "type": "object", "properties": { "users": { "type": "array" } } }
},
{
"method": "POST",
"path": "/v1/bans",
"scope": "admin",
"plugin": "cmd_ban",
"description": "create a ban",
"request_schema": { "type": "object", "required": ["target_type","target"] },
"response_schema": { "type": "object", "properties": { "id": { "type": "string" } } }
}
]
}
}
- A
readtoken seesnone- andread-scoped endpoints (e.g./health). - An
admintoken sees all scopes. - The
/v1/endpointsroute is itself listed in its own output (the registry is self-describing). - Plugin authors who omit
metagetdescription=null, schemasnull. Bundled plugins SHOULD include meta.
10. Endpoint catalogβ
Distinguishes core endpoints (hub-intrinsic, always available
when the listener is bound) from plugin endpoints (registered
when the named plugin is loaded; a disabled plugin has no registered
route, so it answers 404 E_NOT_FOUND like any unknown path).
Authorisation on the HTTP path. Across every endpoint below, the ADC-side level / permission guards (an operator's
permission[level]hierarchy, per-command level tables,_oplevel/_minlevel/_masterlevelgates) do NOT apply. The bearer token's scope -readoradmin- is the sole authorisation gate; issue admin tokens accordingly.
10.1 Core endpointsβ
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /health | none | Health probe, plain text ok |
| GET | /v1/version | read | hub name, version, build, uptime (seconds), start_time (ISO 8601) |
| GET | /v1/stats | read | online user count, total share, traffic, by-level breakdown |
| GET | /v1/users | read | online users list - paginated + filter/sort 1 |
| GET | /v1/users/{sid} | read | full INF + session metadata |
| GET | /v1/endpoints | read | live route registry (scope-filtered) |
| GET | /v1/log/api | admin | tail of this API's own audit log; query ?lines=N (default 200, max 1000); response {lines, returned, total_lines} matches sibling tail endpoints |
| GET | /v1/plugins | read | list plugins in cfg.scripts + runtime state 2 |
| PUT | /v1/plugins/{name}/enabled | admin | toggle a manageable plugin's enabled flag 3 |
| GET | /v1/config | read | full cfg snapshot; sensitive keys masked as <redacted> 4 |
| PUT | /v1/config/{key} | admin | update one cfg key; response carries apply_status 5 |
| GET | /v1/events | read | event stream; ?since=<id>&types=<csv>&wait=<seconds> 6 |
| POST | /v1/auth/verify | read | verify an operator's hub credential via ADC challenge-response (WebUI login) 7 |
10.2 Plugin endpointsβ
Mapped from existing +cmd operations. Each row's plugin column
names the bundled plugin that registers the endpoint; if that plugin
is disabled in cfg.scripts, the endpoint returns 404
E_NOT_FOUND (the router does not distinguish a disabled plugin from
an unknown path).
User controlβ
| Method | Path | Scope | Plugin |
|---|---|---|---|
| DELETE | /v1/users/{sid} | admin | cmd_disconnect |
| POST | /v1/users/{sid}/redirect | admin | cmd_redirect 8 |
| POST | /v1/users/{sid}/gag | admin | cmd_gag 9 |
| DELETE | /v1/users/{sid}/gag | admin | cmd_gag 10 |
Registered usersβ
| Method | Path | Scope | Plugin |
|---|---|---|---|
| GET | /v1/registered | read | cmd_reg - paginated + filter/sort 11 12 |
| GET | /v1/registered/{nick} | read | cmd_accinfo 13 |
| POST | /v1/registered | admin | cmd_reg 14 |
| PUT | /v1/registered/{nick}/password | admin | cmd_setpass 15 |
| PUT | /v1/registered/{nick}/nick | admin | cmd_nickchange 16 |
| PUT | /v1/registered/{nick}/level | admin | cmd_upgrade 17 |
| PATCH | /v1/registered/{nick} | admin | cmd_reg (free-form: comment) 18 |
| DELETE | /v1/registered/{nick} | admin | cmd_delreg - requires X-Confirm: yes (Β§4.6) 19 |
Bans + blacklistβ
| Method | Path | Scope | Plugin |
|---|---|---|---|
| GET | /v1/bans | read | cmd_ban (= +ban show) - paginated + filter/sort 20 21 |
| GET | /v1/bans/history | read | cmd_ban (= +ban showhis); query ?nick= for single-nick history 22 |
| POST | /v1/bans | admin | cmd_ban. body: {target_type: nick|cid|ip|sid, target, duration_minutes?, permanent?, reason?} 23 |
| DELETE | /v1/bans/{id} | admin | cmd_ban 24 |
| GET | /v1/blacklist | read | etc_blacklist - paginated + filter/sort 25 26 |
| DELETE | /v1/blacklist/{nick} | admin | etc_blacklist 27 |
| GET | /v1/clientblocker | read | etc_clientblocker (= ADC +blocker) - landed (#81) 28 |
| POST | /v1/clientblocker | admin | etc_clientblocker (= ADC +addblocker) - landed (#81) 29 |
| DELETE | /v1/clientblocker/{pattern} | admin | etc_clientblocker (= ADC +delblocker) - landed (#81) 30 |
| GET | /v1/blocklist | read | etc_blocklist (= ADC +blocklist show) - paginated + filter/sort 31 |
| GET | /v1/blocklist/counts | read | etc_blocklist (= ADC +blocklist count) - landed (#78 Phase C) 32 |
| POST | /v1/blocklist | admin | etc_blocklist (= ADC +blocklist add) - landed (#78 Phase C) 33 |
| DELETE | /v1/blocklist/{id} | admin | etc_blocklist (= ADC +blocklist del) - landed (#78 Phase C) 34 |
| GET | /v1/whitelist | read | etc_whitelist (= ADC +whitelist show) - paginated + filter/sort 35 |
| GET | /v1/whitelist/counts | read | etc_whitelist (= ADC +whitelist count) - landed (#78 allowlist Phase D) 36 |
| POST | /v1/whitelist | admin | etc_whitelist (= ADC +whitelist add) - landed (#78 allowlist Phase D) 37 |
| DELETE | /v1/whitelist/{id} | admin | etc_whitelist (= ADC +whitelist del) - landed (#78 allowlist Phase D) 38 |
| GET | /v1/geoip | read | etc_geoip - GeoIP policy + MMDB status (country/ASN DB freshness) |
| GET | /v1/proxydetect | read | etc_proxydetect - proxy/VPN detection provider + cache status |
| GET | /v1/blocklist/feeds | read | etc_blocklist_feeds - per-feed refresh status (last pull, entry counts, errors) |
Hub controlβ
| Method | Path | Scope | Plugin |
|---|---|---|---|
| POST | /v1/announce | admin | cmd_mass 39 |
| POST | /v1/topic | admin | cmd_topic 40 |
| GET | /v1/aliases | read | etc_aliases 41 |
| POST | /v1/aliases | admin | etc_aliases 42 |
| DELETE | /v1/aliases/{alias} | admin | etc_aliases 43 |
| POST | /v1/reload | admin | cmd_reload - requires X-Confirm: yes (Β§4.6) 44 |
| POST | /v1/restart | admin | cmd_restart - requires X-Confirm: yes (Β§4.6) 45 |
| POST | /v1/shutdown | admin | cmd_shutdown - requires X-Confirm: yes (Β§4.6) 46 |
Logs + records + runtimeβ
| Method | Path | Scope | Plugin |
|---|---|---|---|
| GET | /v1/log/error?lines=N | admin | cmd_errors 47 |
| GET | /v1/log/cmd?lines=N | admin | etc_cmdlog 48 |
| GET | /v1/log/audit?lines=N | admin | etc_auditlog (#84) 49 |
Subsystem managersβ
| Method | Path | Scope | Plugin |
|---|---|---|---|
| GET | /v1/msgmanager | read | etc_msgmanager - paginated + filter/sort 50 51 |
| POST | /v1/msgmanager/{nick} | admin | etc_msgmanager 52 |
| DELETE | /v1/msgmanager/{nick} | admin | etc_msgmanager 53 |
| GET | /v1/trafficmanager/settings | read | etc_trafficmanager 54 |
| GET | /v1/trafficmanager/blocks | read | etc_trafficmanager - paginated + filter/sort 55 56 |
| POST | /v1/trafficmanager/blocks/{nick} | admin | etc_trafficmanager 57 |
| DELETE | /v1/trafficmanager/blocks/{nick} | admin | etc_trafficmanager 58 |
| GET | /v1/usercleaner/expired | read | cmd_usercleaner - paginated + filter/sort 59 60 |
| DELETE | /v1/usercleaner/expired | admin | cmd_usercleaner - requires X-Confirm: yes (Β§4.6) 61 |
| GET | /v1/usercleaner/ghosts | read | cmd_usercleaner - paginated + filter/sort 62 63 |
| DELETE | /v1/usercleaner/ghosts | admin | cmd_usercleaner - requires X-Confirm: yes (Β§4.6) 64 |
| DELETE | /v1/usercleaner/orphan-comments | admin | cmd_usercleaner - requires X-Confirm: yes (Β§4.6) 65 |
Webhooks (inbound)β
| Method | Path | Scope | Plugin |
|---|---|---|---|
| POST | /v1/webhook/<name> | none | etc_webhook (#398) 66 |
Shipped post-Phase-4 (#82 arc closed 2026-05-27)β
The four "future-scope" items below were shipped in a single day on top of the Phase 1-4 endpoint migrations and are now in the catalog above:
- Plugin management (#261, PR #269) -
GET /v1/plugins,PUT /v1/plugins/{name}/enabled. Listed in Β§10.1. - Config view/edit (#262, PR #272) -
GET /v1/config,PUT /v1/config/{key}with denylist masking on read + apply-status classification. Listed in Β§10.1. - Event polling (#263 PR-A #273 + PR-B #274) -
GET /v1/events?since=...&types=...&wait=.... Polling + long-poll via deferred-response dispatch (NOT SSE). Listed in Β§10.1. - Filter + sort (#264 PR-A #270 + PR-B #271) - common helper
core/http_filter.luawired into every paginated list endpoint. Per-endpoint allowlist documented in each footnote.
True SSE (text/event-stream) is still deliberately deferred -
the long-poll handshake covers the WebUI use cases without the
multi-write iostream rewrite SSE would need.
11. Out-of-scope of the catalogβ
User SELF-service commands (+myinf, +myip, +slots, +sslinfo,
+accinfo self, +nickchange self, +setpass self, +talk,
+uptime, +hubinfo, +rules, +hubstats user-side,
+help) are excluded - users already have an ADC session for these.
Operator-side inspection of an arbitrary user's INF / slots / SSL
info lives in GET /v1/users/{sid} per Β§10.1; the exclusions
listed here are the USER-SELF variants only.
Cosmetic plugins (bot_*, etc_motd, etc_banner, etc_keyprint,
etc_userlogininfo, etc_unknown_command, usr_*) don't expose
actionable operations.
12. Dependenciesβ
dkjson(pure Lua, ~700 LoC): bundled asdkjson/dkjson.lua, registered viacore/init.luaimport block. Decision rationale: pure Lua keeps the build dep-free; performance is fine for an admin API (no 10k req/sec workload).- No new C modules.
13. Implementation phasesβ
Each phase ships as its own sub-PR with its own review gate per CLAUDE.md Β§1a.6. The phases are designed to be independently reviewable and shippable.
Phase 1: core framework + read-only core endpointsβ
- Bundle
dkjsonasdkjson/dkjson.lua; wire it throughcore/init.lua. - Extend
core/iostream.lua:newhttpstageto permit a CL-bounded body for non-GET methods (S3 caps stay; body cap = 64 KiB). - Auto-support HEAD for GET routes; OPTIONS introspection; 405 +
Allowheader for method mismatch (Β§6.6). - New module
core/http_router.lua(or extendcore/http.lua): route table, dispatch, auth, scope, envelope, JSON marshalling, error mapping, rate-limit (token + per-prefix failed-auth), idempotency- key cache, audit log, X-Request-ID generation, schema validation. - New plugin API global
hub.http_register(...)with optionalmeta(description + request_schema + response_schema). +reloadintegration: clear route table before re-running pluginonStartcycle.- First-boot token sample (Β§4.7): generate + write
cfg/api_token.firstwith chmod 600 whenhttp_api_tokensempty. Listener does NOT bind until operator copies the value intocfg.tbl http_api_tokensand restarts (or+reload) (#231). - Core endpoints:
/health(already exists),/v1/version,/v1/stats,/v1/users(paginated),/v1/users/{sid},/v1/endpoints,/v1/log/api. - New cfg keys:
http_api_tokens(table),http_api_rate_read(default 120/min),http_api_rate_admin(default 60/min),http_api_log_reads(default false). - Smoke tests: token resolution + scope check, envelope shape, error
codes (esp. 404 vs 405), idempotency-key behaviour + 5-min TTL +
max-entries cap, rate-limit kick-in (per-token AND per-conn
failed-auth AND prefix-bucket), X-Confirm enforcement, route
registration / re-registration on +reload, idempotency cache
cleared on +reload, pagination clamping (limit=999999 β 1000),
HEAD/OPTIONS auto-response, first-boot bootstrap file generated +
chmoded BEFORE port bind, ISO-8601 timestamp format on both Linux
- Windows MinGW builds (locale-safety of
os.date("!%Y-%m-%dT%H: %M:%SZ", t)), framer body-extension state machine (Β§2.1 test list).
- Windows MinGW builds (locale-safety of
Phase 2: bundled-plugin migration (writes, low-risk)β
Plugins migrate to register their endpoints. Each plugin gets the
register call added in onStart plus a thin handler that calls into
the same code path the +cmd listener uses.
Convention: plugins SHOULD extract the actual operation into a module-local function (e.g.
local function do_ban(target_type, target, dur, reason) ... end). Both the+cmdlistener and the HTTP handler call into it. Avoids duplicating ban logic between the chat side and the API side - a divergence here is the kind of bug that takes months to surface.
cmd_massβPOST /v1/announcecmd_topicβPOST /v1/topiccmd_banβGET/POST /v1/bans,DELETE /v1/bans/{id}cmd_disconnectβDELETE /v1/users/{sid}cmd_gagβPOST/DELETE /v1/users/{sid}/gagcmd_redirectβPOST /v1/users/{sid}/redirectcmd_reg,cmd_delreg,cmd_setpass,cmd_nickchange,cmd_upgrade,cmd_accinfoβ/v1/registered/*familycmd_reloadβPOST /v1/reload
Phase 3: destructive ops + log endpointsβ
cmd_restartβPOST /v1/restart(requiresX-Confirm, see Β§4.6)cmd_shutdownβPOST /v1/shutdown(requiresX-Confirm, see Β§4.6)cmd_errorsβGET /v1/log/erroretc_cmdlogβGET /v1/log/cmdetc_log_cleanerβDELETE /v1/log/{name}
(Note: GET /v1/log/api is in Phase 1 because the router owns the
audit log; the other log endpoints are plugin-owned.)
Phase 4: subsystem manager pluginsβ
etc_blacklist,etc_msgmanager,etc_trafficmanager,etc_records,etc_chatlog,hub_runtime,cmd_usercleaner.
Shipped on top of Phase 1-4 (#82 closed 2026-05-27)β
These four originally "future" items landed as discrete follow-up PRs after the Phase 1-4 endpoint migrations. The #82 arc is now closed; details in their respective PRs / docs.
- Plugin management - #261 (PR #269):
GET /v1/plugins,PUT /v1/plugins/{name}/enabled. Catalog row in Β§10.1. - Config view/edit - #262 (PR #272):
GET /v1/config,PUT /v1/config/{key}. Denylist masking on read; apply-status classification (live/reload_required/restart_required). - Event polling - #263 PR-A (#273) + PR-B (#274):
GET /v1/events?since=&types=&wait=. Polling + long-poll via the deferred-response dispatch handshake; NOT SSE. - Filter + sort - #264 PR-A (#270) + PR-B (#271): shared
helper
core/http_filter.luawired into every paginated list endpoint. Per-endpoint allowlist documented in each footnote.
Still deferredβ
- Server-Sent Events (
text/event-stream). Long-poll covers the current WebUI use cases without the multi-write iostream rewrite SSE needs. Revisit only if hard-realtime emerges. - Unix-domain-socket bind as an alternative to TCP loopback
(
http_socket_path = "/var/run/luadch/api.sock"). luasocket 3.1.0 has bundled AF_UNIX; feasibility confirmed. Deferred YAGNI until a concrete operator request lands. - WebUI itself (separate repo, consumes this API). Was the gating reason for shipping the four items above together.
14. Open questionsβ
- Bulk endpoints.
DELETE /v1/userswith body{sids: [...]}for bulk kick? YAGNI for now; clients can loop. Same call applies to+ban clear/+ban clearhis/+trafficmanagerbulk clears: not surfaced, clients loop client-side. The audit log gets one entry per call this way, which is the right shape for forensic review. - API versioning lifecycle. When does
/v2land? Document a deprecation policy before the first breaking change is needed. Convention:/v1is supported as long as the 3.x major line is current; a/v2rollout overlaps with/v1for one full minor version before/v1is removed. - Schema validation depth. Phase 1 ships the minimal type + required + enum + min/max + min_length/max_length/pattern validator. Full JSON-Schema is out of scope unless a real client demand surfaces.
15. Relatedβ
- Issue #82
- Phase 8 IO substrate:
docs/phases/PHASE_8_IO.md - Plugin model:
docs/PLUGIN_API.md - Security model:
docs/SECURITY.md(loopback-only + reverse-proxy posture lives there)