1 Commits
Author SHA1 Message Date
sneak 3550e790df Rewrite README.md sentence by sentence
check / check (push) Canceled after 0s
Rewritten from scratch against the code on next. The old text was
the list of what a reader needs covered.

Model: fable-5-1
2026-10-25 10:38:07 +00:00
14 changed files with 119 additions and 100 deletions
+3 -1
View File
@@ -72,8 +72,10 @@
**/*.sublime-*
# This repository's own host-built artifacts: the binary `make build`
# writes.
# writes, and the Alpine.js file `make assets` extracts from 3p/ (the
# build extracts its own).
/bin
/static/js/alpine.min.js
# SQLite databases, which hold the session key and webhook payloads, at
# any depth.
+4
View File
@@ -77,3 +77,7 @@ data/
# Temporary files
tmp/
temp/
# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is
# what is committed.
/static/js/alpine.min.js
-1
View File
@@ -1,3 +1,2 @@
node_modules/
yarn.lock
static/js/alpine.min.js
Binary file not shown.
+11 -2
View File
@@ -92,6 +92,10 @@ COPY . .
# fmt-check` is the markdown-check stage above.
RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi
# static/static.go embeds the Alpine.js file this extracts from 3p/; without
# it the static package does not compile and cannot be linted.
RUN script/assets
# The golangci-lint steps run with --network=none. `golangci-lint config
# verify` is documented as fetching its JSON schema over HTTPS; this pinned
# image resolves the schema without any network, and --network=none enforces
@@ -126,6 +130,9 @@ RUN go mod download
COPY . .
# static/static.go embeds the Alpine.js file this extracts from 3p/.
RUN script/assets
# -timeout applies to each package on its own, so 90s has only to clear the
# slowest one. -p 4 -parallel 8 keep the run under 2 GB of memory: at most
# four test binaries build or run at once, each with at most eight parallel
@@ -204,8 +211,10 @@ RUN version="$(make version VERSION="$VERSION")"; \
esac; \
fi
# The Makefile's build target builds with cgo off, so the binary is
# statically linked and runs on the Alpine runtime image below.
# Builds through the Makefile's build target, which runs script/assets
# (Alpine.js, extracted from its tarball in 3p/) first. The target builds
# with cgo off, so the binary is statically linked and runs on the Alpine
# runtime image below as it is.
RUN make build VERSION="$VERSION"
# Runtime stage
+1 -1
View File
@@ -17,7 +17,7 @@ COPY . .
# The test binary embeds the templates and static files, so the browser
# stage needs nothing else. It is built with cgo off and -p 4 keeps the
# compile's memory down, both as in the test phase of Dockerfile.
RUN CGO_ENABLED=0 go test -c -p 4 -tags browser -o /browser.test ./internal/server
RUN make assets && CGO_ENABLED=0 go test -c -p 4 -tags browser -o /browser.test ./internal/server
# chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The
# browser is on PATH as headless-shell, where the test's browser library
+6 -3
View File
@@ -1,4 +1,4 @@
.PHONY: bootstrap setup test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css css-check version
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css css-check version
# Default target
.DEFAULT_GOAL := check
@@ -22,6 +22,9 @@ bootstrap:
setup:
@script/setup
assets:
@script/assets
test:
@script/test
@@ -44,13 +47,13 @@ check:
# REPO_POLICIES.md does: the binary carries no build paths, symbol table or
# debug information, and on Linux, the image's included, it is statically
# linked.
build:
build: assets
CGO_ENABLED=0 go build -trimpath -ldflags '-s -w -X main.version=$(VERSION)' -o bin/webhooker ./cmd/webhooker
run: build
./bin/webhooker
dev:
dev: assets
go run ./cmd/webhooker
deps:
+24 -24
View File
@@ -47,7 +47,7 @@ identifier of its own to the request, so make your targets tolerate a duplicate.
## Getting Started
You need the Go version named in `go.mod`.
You need the Go version named in `go.mod`, and Docker for the checks.
```bash
git clone https://git.eeqj.de/sneak/webhooker.git
@@ -57,8 +57,9 @@ DATA_DIR=../webhooker-data make dev
```
`DATA_DIR` is where webhooker keeps its databases. It defaults to
`/var/lib/webhooker`, so point it at a directory you can write to. `make build`
writes the program to `bin/webhooker`. Running that file starts webhooker.
`/var/lib/webhooker`, so point it at a directory you can write to. On a host
without Docker, `make build` writes the program to `bin/webhooker`, and running
that file starts webhooker.
The first start creates the `admin` account and prints its password once, in a
ruled box on standard output:
@@ -106,9 +107,9 @@ sooner after 24 hours without use, unless `SESSION_IDLE_TIMEOUT` says otherwise.
On the webhook list at `/hooks`, **New Webhook** asks for **Name**, an optional
**Description** and **Retention (days)**. Events older than the retention period
are deleted, along with their deliveries. The default is 30 days. `0` keeps
events forever. Filling in **HTTP target URL** creates an `http` target with the
webhook. Ticking **Archive** creates an archive target. Every new webhook gets
one entrypoint, described as `Default entrypoint`.
events forever. The form also has an **HTTP target URL** field and an
**Archive** checkbox; each creates that target with the webhook. Every new
webhook gets one entrypoint, described as `Default entrypoint`.
### Entrypoint URLs
@@ -197,9 +198,9 @@ Read it with `docker logs` and the name upaas gave the container.
webhooker speaks plain HTTP. It does not handle TLS, the encryption behind
HTTPS: it holds no certificate and accepts no HTTPS connections. The reverse
proxy holds the certificate and reaches webhooker over a private address. A bare
host is a host without Docker. On a bare host the private address is loopback
(`127.0.0.1`), which only this host can reach. For the image it is the Docker
bridge, the private network Docker puts containers on.
host is a host without Docker. On a bare host that is loopback, the address only
this host can reach. For the image it is the Docker bridge, the private network
Docker puts containers on.
Four things must be right.
@@ -370,13 +371,13 @@ writes it. The reverse proxy's access log does hold it, in the request path.
### Sign-in limits
From the fifth failure within a minute, each failure is answered `429`. Only
failures are counted. A correct password is never answered `429`. Failures are
counted per client and submitted username. At most two password checks run at
once; a request that gets no turn within 5 seconds is answered `503`. So
guessing speed is bounded by the limit of two checks at a time, not by the
failure count. Choose a long random `admin` password, or rate-limit
`POST /pages/login` at the reverse proxy.
The password is checked first, and only a failed sign-in is counted. A correct
password is never answered `429`. Failures are counted per client and submitted
username. From the fifth failure within a minute, each failure is answered
`429`. At most two password checks run at once; a request that gets no turn
within 5 seconds is answered `503`. So guessing speed is bounded by the limit of
two checks at a time, not by the failure count. Choose a long random `admin`
password, or rate-limit `POST /pages/login` at the reverse proxy.
### Delivery destinations
@@ -432,11 +433,10 @@ sends them on in the background, and SQLite holds everything in between.
The receiver stores the event and one pending delivery per active target in one
database write, then queues the deliveries. Background workers run them and
record each attempt. At start, a recovery routine resumes every delivery left
`pending` or `retrying`. It then runs every minute. Each run resumes every
`retrying` delivery that the engine is not already handling. It also resumes a
`pending` delivery that has sat untouched for 15 minutes. So a stop or a crash
loses no delivery.
record each attempt. At start, a recovery routine queues again every delivery
left `pending` or `retrying`. It then runs every minute. Each run sends again a
`retrying` delivery the engine is not working on, and a `pending` one that has
sat untouched for 15 minutes. So a stop or a crash loses no delivery.
The main database holds configuration. Each webhook has its own event database,
so writes for one webhook never wait on another webhook's database. The program
@@ -689,9 +689,8 @@ checkout, or `VERSION` when that is set. It is shown at
The pages use one third-party script, Alpine.js. They load `@alpinejs/csp`, the
version of it that works when the Content-Security-Policy forbids running text
as code. The npm registry is where JavaScript packages are published.
`static/js/alpine.min.js` is committed byte for byte as the npm registry
publishes it.
as code. The npm registry is where JavaScript packages are published. The
package file in `3p/` is byte for byte the one the npm registry publishes.
A headless browser is a browser without a window. A browser test in
`internal/server` loads the pages in one under the real Content-Security-Policy.
@@ -711,6 +710,7 @@ application are under [Entrypoint URLs](#entrypoint-urls).
- `script/bootstrap` installs git, make, Go and the Go modules.
- `script/setup` runs `script/bootstrap`, then `script/install-precommit`.
- `script/projectname` prints `webhooker`.
- `script/assets` extracts Alpine.js from `3p/` to `static/js/alpine.min.js`.
- `script/test` runs the tests in Docker.
- `script/test-browser` runs the browser test in Docker.
- `script/lint` runs `gofmt`, `golangci-lint` and ESLint in Docker.
+46 -51
View File
@@ -1,77 +1,72 @@
# Workflow
One issue per unit of work, and one branch and one PR per issue:
One issue per unit of work, one branch and one PR per issue:
- make sure a tracked issue exists with a definition of done
- branch from `next`, never from `main`
- do the work and push the branch
- open a PR with base `next`, never `main`
- pass an independent review
- the repository manager squash-merges it into `next`
- ensure a tracked issue exists with a definition of done
- branch from `next` (never from `main`)
- do the work; open a PR based on `next` (never on `main`)
- pass an independent review, then the manager squash-merges into `next`
- push; nothing stays local-only
The repository manager is the role that merges each issue's PR into `next`.
Nothing stays local-only: every commit is pushed.
`next` is the branch for the next milestone and must stay green and mergeable to
`main` without notice. One `next` -> `main` PR accumulates the milestone;
releases are cut from `main` separately.
`next` is the branch for the next milestone. It must stay green and mergeable
into `main` at all times. A milestone's work reaches `main` through one PR from
`next`. The owner, @sneak, merges that PR. Cutting a release from `main` is a
separate step after that.
The tracker (https://git.eeqj.de/sneak/webhooker/issues) records what is done
and what is under way. This file keeps no list of either.
The tracker (https://git.eeqj.de/sneak/webhooker/issues) is the record of what
is done and what is under way. This file keeps no list of either.
# Status
What is still open for 1.0.0 is in its milestone
(https://git.eeqj.de/sneak/webhooker/milestone/9). `v1.0.0` is not tagged. Only
@sneak tags it.
(https://git.eeqj.de/sneak/webhooker/milestone/9). `v1.0.0` is not tagged, and
only @sneak tags it.
Delivery is at least once by design. When webhooker cannot record the outcome of
a delivery, it sends that delivery again. The target can then receive the same
event twice. The alternative is a lost delivery that nothing reports. The README
says so. Such a duplicate is not a defect to re-file.
Delivery is at-least-once by design, not by accident: a send whose result row
does not land is attempted again, so a receiver can see a duplicate. That is
deliberate — the alternative is a silent lost delivery — and the README says so
under Rationale. It is not a defect to re-file.
# Next Step
@sneak runs webhooker in limited production, then tags `v1.0.0`. Merging `next`
into `main` is a separate act. It waits on neither the production run nor the
tag.
into `main` is a separate act from tagging and waits on neither of those: `next`
is kept mergeable at all times, which is the point of the branch.
# Future Steps
- Per-webhook rate limits, set in the web UI and enforced at the entrypoint.
Today `RECEIVER_RATE_LIMIT` is the only rate-limit setting. It is how many
requests per minute one client address may send to one entrypoint. The same
address is also capped at ten times that across all entrypoints together.
- API key authentication for programmatic access. The `APIKey` model and its
`api_keys` table exist. Nothing creates a key and nothing checks one.
- REST API v1. `/api/v1` is mounted with no routes today.
- CRUD for webhooks, entrypoints and targets
- Per-webhook rate limits, set in the web UI and enforced in the receiver
handler, on top of the receiver's existing limits, which `RECEIVER_RATE_LIMIT`
sets for the whole server (see Rate Limiting in the README)
- API key authentication for programmatic access (the `APIKey` model exists, but
nothing creates a key and no middleware checks one)
- REST API v1
- CRUD for webhooks, entrypoints, targets
- event viewing and filtering endpoints
- event redelivery endpoint
- OpenAPI specification
- Analytics in the web UI: response times summarised per webhook or per target,
and figures for all webhooks together. Today the webhook page shows event,
delivery and failure counts and failure percentages. The target list counts
each target's deliveries. The event log shows every attempt's duration.
`/metrics` carries server-wide counts and delivery durations.
- A remember-me option at login. Today every session survives a browser restart.
It lasts up to 7 days. It ends sooner after `SESSION_IDLE_TIMEOUT` (default 24
hours) without use.
- Password reset over the web for a forgotten password. Today a signed-in user
can change their password. A lost password is reset from the command line with
`webhooker resetpw` (https://git.eeqj.de/sneak/webhooker/issues/208).
and figures for all webhooks together. The webhook page already shows event,
delivery and failure counts and failure percentages, the target list counts
each target's deliveries, the event log and each event's page show every
attempt's duration, and `/metrics` carries server-wide counts and delivery
durations
- A remember-me option at login. Every session already survives a browser
restart: it lasts up to 7 days, and ends sooner after `SESSION_IDLE_TIMEOUT`
(default 24 hours) without activity
- Password reset flow for a forgotten password over the web. The authenticated
password _change_ flow already landed, and a lost password is recoverable from
the console with `webhooker resetpw`
(https://git.eeqj.de/sneak/webhooker/issues/208)
- Later, nice to have
- email delivery target type
- SNS and S3 delivery targets
- data transformations, such as a configurable Slack message format. The
Slack target sends one fixed format today.
- JSONL file delivery with periodic S3 upload. Today an archive target
writes events to SQLite files. An archive target can rotate those files
hourly, daily or monthly. The webhook page offers them as one gzipped JSON
download.
- data transformations (e.g. a configurable Slack message format; the Slack
target sends one fixed format today)
- JSONL file delivery with periodic S3 upload (the `database` target already
archives events to SQLite files, rotated hourly, daily or monthly if set,
and the webhook page offers them as one gzipped JSON download)
- webhook event search, and filtering beyond the event log's failed and
pending views
- multi-user with role-based access control. Today each webhook belongs to
one account. Only the account made at first boot exists. There is no way
to add another.
- multi-user with role-based access control (each webhook already belongs to
one account, but there is only the account made at first boot and no way
to add another)
+1 -1
View File
@@ -5,7 +5,7 @@
// REPO_POLICIES.md states that a linter can check: const for everything,
// let only for a variable that is reassigned, never var.
export default [
// Alpine.js, committed as published; not ours to lint.
// Alpine.js, extracted from 3p/ by make assets; not ours to lint.
{ ignores: ["static/js/alpine.min.js"] },
{
// The pages load static/js/app.js as a classic script, not a module.
+3
View File
@@ -13,6 +13,9 @@ import (
// TestBaseTemplateScriptsAreServed walks every /s/ script the base
// template loads on each page and fetches it through the real router.
// Alpine.js is extracted from its tarball in 3p/ at build time, so the
// file is not in the tree: this is the check that the page still gets
// the JavaScript it asks for.
func TestBaseTemplateScriptsAreServed(t *testing.T) {
t.Parallel()
Executable
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# script/assets: extract Alpine.js from its npm package tarball, committed
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The package
# is @alpinejs/csp, Alpine's build for pages whose Content-Security-Policy
# forbids eval. The extracted file is not committed. make build, make dev
# and the Dockerfile's lint, test and build stages run this first.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
tar -xzOf 3p/alpinejs-csp-3.14.9.tgz package/dist/cdn.min.js \
>static/js/alpine.min.js
}
main "$@"
-11
View File
File diff suppressed because one or more lines are too long
+3 -5
View File
@@ -6,11 +6,9 @@ import (
)
// Static holds the CSS and JavaScript files the web UI's pages load. They
// are named one by one, so a missing file fails the build instead of
// leaving the pages without it.
//
// js/alpine.min.js is Alpine.js, committed unchanged. It is
// package/dist/cdn.min.js from the npm package @alpinejs/csp 3.14.9.
// are named one by one so that a missing js/alpine.min.js, which make
// assets extracts and git does not track, fails the build instead of
// leaving the pages without Alpine.js.
//
//go:embed css/tailwind.css css/style.css js/app.js js/alpine.min.js
var Static embed.FS