Skip to content

Commit fff195b

Browse files
Merge pull request #9 from OneBusAway/user-instructions
docs: reshuffle README around tasks; split quickstart into deployment + ops
2 parents 6ac0a17 + 492b405 commit fff195b

6 files changed

Lines changed: 261 additions & 285 deletions

File tree

README.md

Lines changed: 139 additions & 97 deletions
Large diffs are not rendered by default.

docs/accounts.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,37 @@
11
# Developer accounts
22

3-
This walkthrough covers a deployed `hooks` relay (Render or any other host you don't shell into). For the local end-to-end demo, see the [README](../README.md). For the security-focused breakdown, see [`docs/security.md`](security.md).
3+
This walkthrough covers a deployed `hooks` relay (Render or any other host you don't shell into). For the deployment recipe and a local-only path, see the [README](../README.md). For the security-focused breakdown, see [`docs/security.md`](security.md).
44

55
The mental model: one relay deployment per team. Each developer has their own account. Listener tokens, push subscriptions, and PATs (personal access tokens) are owned by users. Deactivating a user revokes their tokens and pauses their push subscriptions in one move.
66

77
## 1. Deploy the relay
88

9-
Build the binaries and bring up the server behind TLS (`docs/quickstart.md` covers the deployment shape). Whatever the host, the URL the rest of this doc uses is `https://hooks.example.com`.
9+
Build the binaries and bring up the server behind TLS (the [README](../README.md) has the recipe; [`deployment.md`](deployment.md) has the reference). Whatever the host, the URL the rest of this doc uses is `https://webhooks.example.com`.
1010

1111
## 2. Bootstrap the first admin
1212

1313
On a freshly initialized database `hooks init` prints a one-time signup URL:
1414

1515
```
1616
admin token (shown ONCE): <legacy system token, copy if you want one>
17-
signup: https://hooks.example.com/signup?code=ABCDEFGH...
17+
signup: https://webhooks.example.com/signup?code=ABCDEFGH...
1818
(single-use; expires in 24h; auto-disables once any account exists)
1919
```
2020

21-
Open the signup URL in a browser. Pick an email, name, and password (≥ 12 characters; must not contain your email's local-part). The first signup consumes the bootstrap invite; once any user exists, that URL returns 409 even if someone else copies it.
21+
Open the signup URL in a browser. Pick an email, name, and password (≥ 12 characters; must not contain your full email — or its local-part, when the local-part is at least three characters long). The first signup consumes the bootstrap invite; once any user exists, that URL returns 409 even if someone else copies it.
2222

2323
The `admin token` printed alongside the signup URL is the legacy system credential. You can keep it for break-glass access or revoke it with `hooksctl token revoke <id>` once your PAT is working (see step 4). It is **not** tied to any user account.
2424

2525
If the bootstrap link expires before it's used, re-run `hooks init` against the still-userless DB to regenerate it; a fresh 24-hour window starts.
2626

2727
## 3. Invite teammates
2828

29-
After login, the inspector at `/users` exposes an "Issue invite" form. Pick a role (`user` or `admin`) and a default scope set; the page shows the resulting `https://hooks.example.com/signup?code=...` URL once. Send it to your teammate. Invites are single-use.
29+
After login, the inspector at `/users` exposes an "Issue invite" form. Pick a role (`user` or `admin`) and a default scope set; the page shows the resulting `https://webhooks.example.com/signup?code=...` URL once. Send it to your teammate. Invites are single-use.
3030

3131
The same surface is available over JSON at `POST /api/invites` for any tool you'd rather drive programmatically:
3232

3333
```sh
34-
curl -X POST https://hooks.example.com/api/invites \
34+
curl -X POST https://webhooks.example.com/api/invites \
3535
-H "Authorization: Bearer $ADMIN_PAT" \
3636
-H "Content-Type: application/json" \
3737
-d '{"role": "user", "default_scopes": ["render"]}'
@@ -40,16 +40,16 @@ curl -X POST https://hooks.example.com/api/invites \
4040
## 4. Get a CLI on your laptop
4141

4242
```sh
43-
hooksctl login --server https://hooks.example.com
43+
hooksctl login --server https://webhooks.example.com
4444
```
4545

4646
The CLI prints a short user code (`ABCD-EFGH`) and a verification URL, and tries to open the URL in your browser. The page asks you to log in if you aren't already, then shows you the requesting client's user-agent, IP, and requested scopes. **Approval requires you to re-enter your password**, even if you're already logged in — a live session alone is not sufficient.
4747

4848
Default scope on approval is `account` only — enough to manage your own tokens but not enough to subscribe to webhook events. Pass `--scopes` (comma-separated source names) to request more, and `--admin` to request admin scope:
4949

5050
```sh
51-
hooksctl login --server https://hooks.example.com --scopes render,stripe
52-
hooksctl login --server https://hooks.example.com --admin
51+
hooksctl login --server https://webhooks.example.com --scopes render,stripe
52+
hooksctl login --server https://webhooks.example.com --admin
5353
```
5454

5555
You may also narrow the scopes from the approval page itself — the CLI's request is the upper bound. Approval mints a personal access token (PAT), writes it to `${XDG_CONFIG_HOME:-$HOME/.config}/hooks/credentials.<profile>` (mode `0600`), and the next CLI call uses it automatically.
@@ -119,7 +119,7 @@ The API requires a `confirm=<email>` body field; the inspector form requires you
119119

120120
```sh
121121
# Move a token to a different user.
122-
curl -X PATCH https://hooks.example.com/api/tokens/<id> \
122+
curl -X PATCH https://webhooks.example.com/api/tokens/<id> \
123123
-H "Authorization: Bearer $ADMIN_PAT" \
124124
-d '{"owner_user_id": "<new owner id>"}'
125125
```

docs/deployment.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Deployment reference
2+
3+
The [README](../README.md) covers the recipe for the three supported deployment paths (Render Blueprint, `docker run`, bare binary). This doc is the reference: env vars, init flags, container internals, what each path does and doesn't set up.
4+
5+
## TLS termination
6+
7+
The relay binds plain HTTP. Stand any TLS-terminating reverse proxy in front of it. Providers refuse non-HTTPS endpoints, so this is enforced by the outside world either way.
8+
9+
- **Caddy**`webhooks.example.com { reverse_proxy localhost:8080 }`. Caddy obtains a Let's Encrypt cert automatically.
10+
- **nginx** — standard `proxy_pass http://127.0.0.1:8080;` block, plus your existing TLS config.
11+
- **Cloudflare Tunnel / Render / fly.io** — any platform that gives you HTTPS in front of an HTTP origin.
12+
13+
Wire your load balancer's health check to `/readyz` (it pings SQLite end-to-end). `/healthz` is liveness-only.
14+
15+
## `hooks init`
16+
17+
`hooks init` is the bootstrap command. It writes `hooks.yaml`, creates `hooks.db`, mints a one-time **admin token** (the legacy system credential), and — when the users table is empty — prints a one-time **bootstrap signup URL** (24-hour TTL, single-use, auto-disables once any user exists):
18+
19+
```text
20+
admin token (shown ONCE): <long base64 string>
21+
signup: https://webhooks.example.com/signup?code=ABCDEFGH...
22+
(single-use; expires in 24h; auto-disables once any account exists)
23+
```
24+
25+
Save both. The admin token has no recovery path; the signup URL is how the first human creates their admin account. If the bootstrap link expires before it's used, re-run `hooks init --force` against the still-userless DB to mint a fresh 24-hour invite.
26+
27+
Notable flags:
28+
29+
- `--server-url <url>` (or `HOOKS_PUBLIC_URL`) — host to use when printing the signup URL. Skip it and the URL prints with a `localhost` placeholder you'll have to swap by hand.
30+
- `--dir <path>` — directory for `hooks.yaml` and `hooks.db`. Used by the container entrypoint (`--dir /data`).
31+
- `--force` — re-mint the bootstrap signup URL on a still-userless DB. Once any user exists, the bootstrap path is closed and `--force` does nothing useful.
32+
- `--token-name <name>` — name for the generated admin token (default `operator`). Cosmetic only — surfaced in `hooksctl token list`.
33+
34+
What `hooks init` does **not** do:
35+
36+
- Stand up your reverse proxy. That's on you.
37+
- Register the provider-side webhook. That's a step in the provider's dashboard.
38+
- Persist the admin token plaintext or the bootstrap signup URL anywhere except standard out. Save them — neither is recoverable. (The token's Argon2id hash lives in the database, but the plaintext is shown only once.)
39+
40+
## Env vars and config precedence
41+
42+
Listen-address precedence: `HOOKS_LISTEN_ADDR` > yaml `listen_addr` > `:$PORT` (when `$PORT` is a valid port — for Render/Heroku/Fly/Cloud Run) > `:8080`.
43+
44+
Other env vars:
45+
46+
- `HOOKS_DATABASE_URL` — SQLite path. Defaults to `./hooks.db`; the Docker image overrides to `/data/hooks.db`.
47+
- `HOOKS_LOG_LEVEL``debug`, `info`, `warn`, `error`. Default `info`.
48+
- `HOOKS_PUBLIC_URL` *(optional)* — host used when printing the bootstrap signup URL.
49+
- Provider secrets (e.g. `RENDER_WEBHOOK_SECRET`) — referenced from `hooks.yaml` via `${VAR}` interpolation.
50+
51+
`hooks.yaml` supports `${VAR}` and `${VAR:-default}` interpolation. A `tokens:` field is rejected at load time — listener tokens live in the database, not in YAML. Every source must declare a `verifier:`; unsigned sources are not supported.
52+
53+
Defaults: body size limit 1 MiB, dedupe window 24h, skew window 5m, retention 30d per source. Retention `0` / `forever` / `never` disables auto-prune for that source.
54+
55+
## The container image
56+
57+
The `Dockerfile` is a multi-stage build (Go builder → small Alpine runtime). It runs as UID 65532, mounts `/data` as a volume for the SQLite database, and ships both `hooks` and `hooksctl` so you can `docker exec` to manage tokens, push subscriptions, and pruning.
58+
59+
Image defaults:
60+
61+
- `HOOKS_DATABASE_URL=/data/hooks.db`
62+
- Listens on `:8080` (or `$PORT` if set)
63+
- A Dockerfile-level `HEALTHCHECK` polls `/healthz`. Behind a load balancer, prefer `/readyz`.
64+
65+
The image's entrypoint (`docker-entrypoint.sh`) detects an empty `/data` (no `hooks.yaml` and no `hooks.db`) on first boot and runs `hooks init --dir /data` automatically. Without this, Render Blueprint deploys would crash-loop on first boot — the volume is empty, the server can't read `hooks.yaml`, and Render's Shell tab is gated on a running instance, so the documented recovery path would be unreachable. The auto-init prints the one-time admin token and bootstrap signup URL to stdout (which lands in the platform's log stream — treat both as secrets).
66+
67+
Subcommands (`init`, `invite`, `prune`, `verify`, `help`) bypass the bootstrap.
68+
69+
## Render Blueprint specifics
70+
71+
The repo includes a `render.yaml` Blueprint:
72+
73+
- Single instance only. The SQLite store is a single-writer design; two pods against the same disk corrupt it. `numInstances: 1` is intentional.
74+
- 1 GiB persistent disk mounted at `/data`.
75+
- `HOOKS_DATABASE_URL=/data/hooks.db` is set in the Blueprint.
76+
- `HOOKS_PUBLIC_URL` and `RENDER_WEBHOOK_SECRET` are declared `sync: false` — set them in the service's **Environment** tab before the first deploy.
77+
- No `HOOKS_LISTEN_ADDR`. The server honors `$PORT` (which Render injects) automatically.
78+
- `/readyz` is the health check.
79+
80+
Both `hooks` and `hooksctl` are on `$PATH` in the Render Shell, so token rotation, push-subscription management, and pruning all work without leaving the platform.
81+
82+
## Single-process / SQLite limitation
83+
84+
The default deployment is one process with SQLite. There is **no** built-in coordination for multi-process; SSE delivery and push dispatch assume a single in-process notifier. The storage interface is shaped to accept a Postgres backend later (and a Redis/NATS pub/sub for cross-process notifications), but that code isn't written yet — running two `hooks` processes against the same SQLite file is unsafe.
85+
86+
## Skew-window semantics on initial backfill
87+
88+
`hooksctl forward` replays from the cursor on connect, then tails live. **Initial backfill is bounded by the source's signature-verification skew window** (`skew_window` per source in `hooks.yaml`, or 5 minutes when unset). Events older than that window are skipped on the initial drain so a verifying consumer doesn't 401 on a stale `webhook-timestamp`. The cursor still advances past skipped events, so reconnects don't reconsider them.
89+
90+
Skipped events remain in the store. Redeliver them via the inspector ("Replay to listeners") or `hooksctl replay`.
91+
92+
This filter is **only on the initial backfill**. Live tail (notifier-triggered or keepalive-triggered drains) is unfiltered, so manual replays via the inspector still reach currently-connected subscribers.

0 commit comments

Comments
 (0)