Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 139 additions & 97 deletions README.md

Large diffs are not rendered by default.

20 changes: 10 additions & 10 deletions docs/accounts.md
Original file line number Diff line number Diff line change
@@ -1,37 +1,37 @@
# Developer accounts

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).
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).

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.

## 1. Deploy the relay

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`.
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`.

## 2. Bootstrap the first admin

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

```
admin token (shown ONCE): <legacy system token, copy if you want one>
signup: https://hooks.example.com/signup?code=ABCDEFGH...
signup: https://webhooks.example.com/signup?code=ABCDEFGH...
(single-use; expires in 24h; auto-disables once any account exists)
```
Comment on lines 15 to 19
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add language specifier to fenced code block.

The code block should specify a language identifier for proper rendering. Since this is output text from the hooks init command, text or console would be appropriate.

📝 Proposed fix
-```
+```text
 admin token (shown ONCE): <legacy system token, copy if you want one>
 signup: https://webhooks.example.com/signup?code=ABCDEFGH...
         (single-use; expires in 24h; auto-disables once any account exists)
</details>

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.22.1)</summary>

[warning] 15-15: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @docs/accounts.md around lines 15 - 19, The fenced code block in
docs/accounts.md is missing a language specifier which prevents proper
rendering; update the triple-backtick opening fence for the block that starts
with "admin token (shown ONCE): <legacy system token, copy if you want one>" to
include a language identifier (e.g., use text or console) so the snippet
renders correctly.


</details>

<!-- fingerprinting:phantom:triton:puma -->

<!-- d98c2f50 -->

<!-- This is an auto-generated comment by CodeRabbit -->


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.
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.

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.

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.

## 3. Invite teammates

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.
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.

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

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

```sh
hooksctl login --server https://hooks.example.com
hooksctl login --server https://webhooks.example.com
```

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.

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:

```sh
hooksctl login --server https://hooks.example.com --scopes render,stripe
hooksctl login --server https://hooks.example.com --admin
hooksctl login --server https://webhooks.example.com --scopes render,stripe
hooksctl login --server https://webhooks.example.com --admin
```

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.
Expand Down Expand Up @@ -119,7 +119,7 @@ The API requires a `confirm=<email>` body field; the inspector form requires you

```sh
# Move a token to a different user.
curl -X PATCH https://hooks.example.com/api/tokens/<id> \
curl -X PATCH https://webhooks.example.com/api/tokens/<id> \
-H "Authorization: Bearer $ADMIN_PAT" \
-d '{"owner_user_id": "<new owner id>"}'
```
Expand Down
92 changes: 92 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Deployment reference

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.

## TLS termination

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.

- **Caddy** — `webhooks.example.com { reverse_proxy localhost:8080 }`. Caddy obtains a Let's Encrypt cert automatically.
- **nginx** — standard `proxy_pass http://127.0.0.1:8080;` block, plus your existing TLS config.
- **Cloudflare Tunnel / Render / fly.io** — any platform that gives you HTTPS in front of an HTTP origin.

Wire your load balancer's health check to `/readyz` (it pings SQLite end-to-end). `/healthz` is liveness-only.

## `hooks init`

`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):

```text
admin token (shown ONCE): <long base64 string>
signup: https://webhooks.example.com/signup?code=ABCDEFGH...
(single-use; expires in 24h; auto-disables once any account exists)
```

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.

Notable flags:

- `--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.
- `--dir <path>` — directory for `hooks.yaml` and `hooks.db`. Used by the container entrypoint (`--dir /data`).
- `--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.
- `--token-name <name>` — name for the generated admin token (default `operator`). Cosmetic only — surfaced in `hooksctl token list`.

What `hooks init` does **not** do:

- Stand up your reverse proxy. That's on you.
- Register the provider-side webhook. That's a step in the provider's dashboard.
- 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.)

## Env vars and config precedence

Listen-address precedence: `HOOKS_LISTEN_ADDR` > yaml `listen_addr` > `:$PORT` (when `$PORT` is a valid port — for Render/Heroku/Fly/Cloud Run) > `:8080`.

Other env vars:

- `HOOKS_DATABASE_URL` — SQLite path. Defaults to `./hooks.db`; the Docker image overrides to `/data/hooks.db`.
- `HOOKS_LOG_LEVEL` — `debug`, `info`, `warn`, `error`. Default `info`.
- `HOOKS_PUBLIC_URL` *(optional)* — host used when printing the bootstrap signup URL.
- Provider secrets (e.g. `RENDER_WEBHOOK_SECRET`) — referenced from `hooks.yaml` via `${VAR}` interpolation.

`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.

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.

## The container image

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.

Image defaults:

- `HOOKS_DATABASE_URL=/data/hooks.db`
- Listens on `:8080` (or `$PORT` if set)
- A Dockerfile-level `HEALTHCHECK` polls `/healthz`. Behind a load balancer, prefer `/readyz`.

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).

Subcommands (`init`, `invite`, `prune`, `verify`, `help`) bypass the bootstrap.

## Render Blueprint specifics

The repo includes a `render.yaml` Blueprint:

- Single instance only. The SQLite store is a single-writer design; two pods against the same disk corrupt it. `numInstances: 1` is intentional.
- 1 GiB persistent disk mounted at `/data`.
- `HOOKS_DATABASE_URL=/data/hooks.db` is set in the Blueprint.
- `HOOKS_PUBLIC_URL` and `RENDER_WEBHOOK_SECRET` are declared `sync: false` — set them in the service's **Environment** tab before the first deploy.
- No `HOOKS_LISTEN_ADDR`. The server honors `$PORT` (which Render injects) automatically.
- `/readyz` is the health check.

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.

## Single-process / SQLite limitation

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.

## Skew-window semantics on initial backfill

`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.

Skipped events remain in the store. Redeliver them via the inspector ("Replay to listeners") or `hooksctl replay`.

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.
Loading
Loading