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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
hooks.yaml
.env
.claude/scheduled_tasks.lock
.playwright-mcp/
5 changes: 5 additions & 0 deletions .sonarcloud.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Test files routinely reuse the same NewRequest → Do → ReadAll → Close
# boilerplate per test case (DAMP > DRY for tests). SonarCloud's CPD treats
# that as duplication and blocks the merge gate. Production code is still
# subject to CPD.
sonar.cpd.exclusions=**/*_test.go
12 changes: 7 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,19 @@ inbound webhook ──► /ingest/<source> ──► verifier ──► store.Ap

- **`internal/push`** — `Manager` runs one worker goroutine per non-paused subscription. Workers POST events one at a time, advancing cursor only on 2xx. Backoff is `min(60s, 2^failures*100ms)` with full jitter. Outbound delivery signature: `X-Hooks-Signature: t=<unix>,v1=<hex>` where `v1 = HMAC-SHA256(secret, "<unix>.<body>")` (see `signing.go`). **The plaintext signing secret only lives in memory** — after a restart, push delivery for each subscription is paused until `hooksctl push rotate-secret <id>` re-arms it. This is a deliberate trade-off (don't try to "fix" by persisting plaintext).

- **`internal/tokens`** — listener bearer tokens, Argon2id-hashed at rest. The store package can't import argon2 directly, so `tokens.AttachVerifier(st)` injects the hash-compare function at startup. `LookupByPlaintext` is O(N) per request (re-hashes per row); fine for operator-token volumes. The special scope `admin` grants access to `/inspector` and the management APIs but does NOT implicitly grant subscribe — admin tokens MUST also list source names in scopes to use `/subscribe/<source>`.
- **`internal/tokens`** — listener bearer tokens, Argon2id-hashed at rest. The store package can't import argon2 directly, so `tokens.AttachVerifier(st)` injects the hash-compare function at startup. `LookupByPlaintext` is O(N) per request (re-hashes per row); fine for operator-token volumes. The special scope `admin` grants access to the inspector and the management APIs but does NOT implicitly grant subscribe — admin tokens MUST also list source names in scopes to use `/subscribe/<source>`.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
- **`internal/secret`** — `secret.String` is a typed credential that returns `[REDACTED]` on `String()`, `GoString()`, and `MarshalJSON`. Always use it for secrets crossing config/log boundaries. Convert to plaintext only at the consumption site via `.Reveal()`. Use `secret.Equal` / `secret.EqualString` for constant-time comparison.

- **`internal/config`** — loads `hooks.yaml`, applies env interpolation (`${VAR}` and `${VAR:-default}`), then env-var overrides (`HOOKS_LISTEN_ADDR`, `HOOKS_DATABASE_URL`, `HOOKS_LOG_LEVEL`). Listen-address precedence is `HOOKS_LISTEN_ADDR` > yaml `listen_addr` > `:$PORT` (when `$PORT` is a valid port — for Render/Heroku/Fly/Cloud Run) > `:8080`. **A `tokens:` field is rejected at load time** — listener tokens live in the database, not YAML. `verifier:` is required for every source; unsigned sources are not supported. Defaults: `BodySizeLimit=1MiB`, `DedupeWindow=24h`, `SkewWindow=5m`, source `Retention=30d`. Retention `0` / `forever` / `never` disables auto-prune for that source.

- **`internal/inspector`** — admin-only web UI under `/inspector`. Templates and static assets are `//go:embed`-ed; the binary is fully self-contained. Auth is either a `hooks_session=<id>.<plaintext>` cookie (post-login, server-side `user_sessions` row, SHA-256 hashed) or — legacy — a cookie carrying the plaintext bearer token (Argon2id-hashed lookup, identical to API auth). New logins always create a `user_sessions` row; the legacy path is accept-on-read only.
- **`internal/inspector`** — admin web UI mounted at the server root (`/`, `/me`, `/tokens`, `/push`, `/users`, `/audit`, `/events/{source}/{seq}`, …). Static assets are served at `/assets/stylesheets/`. Templates and assets are `//go:embed`-ed; the binary is fully self-contained. Auth is the `hooks_session=<id>.<plaintext>` cookie issued by `/login` (server-side `user_sessions` row, SHA-256 hashed). No bearer-token or basic-auth path; anonymous and non-session callers redirect to `/login`.

- **`internal/prune`** — hourly per-source pruner that respects each source's configured retention. The `hooks prune --older-than <dur>` CLI bypasses configured retention for ad-hoc cleanup. Also reaps `ephemeral=true` listener tokens whose `last_used_at` is more than 24h in the past (forward crash-safety net) and `device_pairings` rows 24h after terminal state.

- **`internal/users`** — `users` table (id, email, name, role, password_hash Argon2id, default_scopes, deactivated_at). Owns signup-time password policy enforcement (length ≥ 12, no email substring; failed-policy reason logged, never the plaintext). `Deactivate` is the cascading-revoke path: in one tx it sets `deactivated_at`, revokes every PAT/listener token, and pauses every push subscription owned by the user. A last-admin guard refuses with HTTP 409 if zero admins would remain (checked before AND inside the tx). Reactivation flips `deactivated_at` to NULL and **does not** restore tokens or unpause subscriptions — the user must reissue. Matches GitHub's UX; documented in `docs/security.md`.

- **`internal/audit`** — append-only `audit_events` table surfaced at `/inspector/audit` (admin). Recorder hangs off mutating handlers (invites, users, tokens, sessions, device pairings). Prune loop does not touch this table; metadata is small (operator-action volume, not webhook volume).
- **`internal/audit`** — append-only `audit_events` table surfaced at `/audit` (admin). Recorder hangs off mutating handlers (invites, users, tokens, sessions, device pairings). Prune loop does not touch this table; metadata is small (operator-action volume, not webhook volume).

- **`internal/ratelimit`** — in-process token-bucket-per-IP (and per-user, for device-approve) middleware. Wired onto auth surfaces in `internal/server.registerAuthRoutes`. Buckets live in process memory and reset on restart — acceptable for the single-process SQLite posture. Limits live next to the route registration; check `internal/server/server.go` for current values.

Expand All @@ -90,8 +90,10 @@ inbound webhook ──► /ingest/<source> ──► verifier ──► store.Ap

Listener tokens and PATs share a row schema (`listener_tokens`) but are routed by `kind` at lookup time:

- **`kind='listener'`** — authorizes `/subscribe/<source>` and (when admin-scoped) the inspector. Cannot reach `/api/me/*`.
- **`kind='pat'`** — owned by a user; authorizes `/api/me/*` and the inspector. Cannot subscribe to event traffic.
- **`kind='listener'`** — authorizes `/subscribe/<source>` and (when admin-scoped) the JSON management APIs (`/api/tokens`, `/api/push-subscriptions`). Cannot reach `/api/me/*`.
- **`kind='pat'`** — owned by a user; authorizes `/api/me/*`. Cannot subscribe to event traffic.

Neither token kind authenticates the inspector web UI — that's session-cookie only. Bearer tokens are for `hooksctl` and direct API consumers.

Setting `owner_user_id=NULL` is reserved for system tokens minted by `hooks init` or `hooksctl token add` against an empty DB. NULL ownership does not mutate scopes — system tokens retain whatever scopes they were minted with.

Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,14 @@ sources:
`--dev` enables verbose logging, opens the inspector in your browser, and prints the URLs you'll need:

```
inspector: http://localhost:8080/inspector
inspector: http://localhost:8080/
ingest: http://localhost:8080/ingest/render
forward: hooksctl forward render --to http://localhost:3000/webhooks/render
```

Leave it running.
The inspector tab will land on `/login`. To claim your admin account, open the signup URL from step 2 (`http://localhost:8080/signup?code=...`) in the same browser, pick an email + password, and you'll be signed in to the inspector. The `HOOKS_TOKEN` you exported is for `hooksctl`, not the browser.

Leave the relay running.

### 5. Open a public HTTPS tunnel to it

Expand Down Expand Up @@ -111,7 +113,7 @@ The fastest trigger is a redeploy of any Render service: `Manual Deploy → Depl
You should see, in order:

- A `POST /ingest/render` log line in the `hooks --dev` terminal.
- A new row in the inspector at `http://localhost:8080/inspector` (paste the admin token to log in).
- A new row in the inspector at `http://localhost:8080/` (sign in with the admin email/password you set during `hooks init`).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- A `POST /webhooks/render` arriving at your local app via the `hooksctl forward` terminal.

### 8. (Optional) Register a long-lived push subscription
Expand Down Expand Up @@ -158,7 +160,7 @@ See [`docs/accounts.md`](docs/accounts.md) for the full walkthrough (scopes, adm
- **HTTP 401 in the relay logs** — secret mismatch between `RENDER_WEBHOOK_SECRET` and what Render is signing with. Re-copy the signing secret from the Render dashboard.
- **HTTP 404** — the URL path is wrong; it must end in `/ingest/render`.
- **No request reaches the relay at all** — confirm the tunnel URL works in your browser (`/healthz` should return `ok`), and that you saved the updated URL in the Render dashboard.
- **The `--dev` browser tab won't authenticate** — paste the admin token printed by `hooks init`, not your Render secret.
- **The `--dev` browser tab won't authenticate** — open the signup URL printed by `hooks init` to claim an admin account first; the inspector signs you in with email/password, not the admin token.

# LICENSE

Expand Down
4 changes: 2 additions & 2 deletions cmd/hooks/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,14 +125,14 @@ func printDevQuickstart(srv *server.Server) {
}
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "hooks --dev quickstart:")
fmt.Fprintf(os.Stderr, " inspector: http://%s/inspector\n", host)
fmt.Fprintf(os.Stderr, " inspector: http://%s/\n", host)
for source := range srv.Cfg.Sources {
fmt.Fprintf(os.Stderr, " ingest: http://%s/ingest/%s\n", host, source)
fmt.Fprintf(os.Stderr, " forward: hooksctl forward %s --to http://localhost:3000/webhooks/%s\n", source, source)
fmt.Fprintf(os.Stderr, " push add: hooksctl push add --source %s --to https://my-svc.example.com/hooks\n", source)
}
fmt.Fprintln(os.Stderr, "")
openBrowser(fmt.Sprintf("http://%s/inspector", host))
openBrowser(fmt.Sprintf("http://%s/", host))
}

func openBrowser(url string) {
Expand Down
6 changes: 3 additions & 3 deletions docs/accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ If the bootstrap link expires before it's used, re-run `hooks init` against the

## 3. Invite teammates

After login, the inspector at `/inspector/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://hooks.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:

Expand Down Expand Up @@ -101,7 +101,7 @@ The relay prints a per-subscription signing secret **once** — store it on your

## Admin operations

The inspector exposes admin-only pages at `/inspector/users` (user list + issue-invite form + deactivate / reactivate / reset-password actions) and `/inspector/audit` (audit-log table). The matching JSON endpoints are under `/api/users/*`, `/api/invites/*`, `/api/audit`. v1 ships no `hooksctl` subcommands for these surfaces — drive them via the inspector or `curl` against the JSON API.
The inspector exposes admin-only pages at `/users` (user list + issue-invite form + deactivate / reactivate / reset-password actions) and `/audit` (audit-log table). The matching JSON endpoints are under `/api/users/*`, `/api/invites/*`, `/api/audit`. v1 ships no `hooksctl` subcommands for these surfaces — drive them via the inspector or `curl` against the JSON API.

### Deactivating a user

Expand Down Expand Up @@ -136,7 +136,7 @@ Every admin-meaningful action lands in `audit_events`:
- `session.create`, `session.delete`
- `device_pairing.start`, `device_pairing.approve`, `device_pairing.deny`

Surfaced at `/inspector/audit` (admin only). The table is **append-only** — no API or UI deletes entries. Size is small (~few hundred bytes per row, growth driven by operator actions, not webhook traffic).
Surfaced at `/audit` (admin only). The table is **append-only** — no API or UI deletes entries. Size is small (~few hundred bytes per row, growth driven by operator actions, not webhook traffic).

## Logging out

Expand Down
2 changes: 1 addition & 1 deletion docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ The default deployment is one process with SQLite. There is **no** built-in coor

## Push-subscription health

Use `/inspector/push` (or `hooksctl push list`) to monitor:
Use `/push` (or `hooksctl push list`) to monitor:

- **Queue depth**: `latest_sequence_for_source - cursor`. Grows during outages; should return to 0 within seconds after recovery.
- **`consecutive_failures`**: resets to 0 on the next 2xx.
Expand Down
6 changes: 3 additions & 3 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,9 @@ The server honors `$PORT` (which Render injects) automatically, so the Blueprint

## 4. Claim the first admin account

Open the bootstrap signup URL from step 2 in a browser. Pick an email, name, and password (≥ 12 characters; must not contain your email or its local-part). Submitting the form consumes the bootstrap invite, signs you into the inspector at `/inspector`, and the URL returns 409 from then on.
Open the bootstrap signup URL from step 2 in a browser. Pick an email, name, and password (≥ 12 characters; must not contain your email or its local-part). Submitting the form consumes the bootstrap invite, signs you into the inspector at `/`, and the URL returns 409 from then on.

If the link expires before you use it, open the service's **Shell** (now available since the deploy is healthy) and re-run `hooks init --force --server-url "$HOOKS_PUBLIC_URL"` to mint a fresh 24-hour invite. Once any user exists, the bootstrap path is closed — invite teammates from `/inspector/users` (or `POST /api/invites`) instead.
If the link expires before you use it, open the service's **Shell** (now available since the deploy is healthy) and re-run `hooks init --force --server-url "$HOOKS_PUBLIC_URL"` to mint a fresh 24-hour invite. Once any user exists, the bootstrap path is closed — invite teammates from `/users` (or `POST /api/invites`) instead.

## 5. Register the webhook with Render

Expand Down Expand Up @@ -153,7 +153,7 @@ The plaintext signing secret only lives in memory, so push delivery for each sub

## 9. Browse

Open `https://webhooks.example.com/inspector` and sign in with the email/password from step 4. You can browse every captured event, replay any of them to live listeners, manage tokens and push subscriptions, invite teammates, and review the audit log at `/inspector/audit`.
Open `https://webhooks.example.com/` and sign in with the email/password from step 4. You can browse every captured event, replay any of them to live listeners, manage tokens and push subscriptions, invite teammates, and review the audit log at `/audit`.

## What `hooks init` does NOT do

Expand Down
4 changes: 2 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Failed verification produces HTTP 401 with no body. Logs include only the source
- `hooksctl token list` and `/api/tokens` GET return only metadata (id, name, scopes, timestamps). No path returns the plaintext after issuance.
- Revoked tokens are rejected within one round-trip; `last_used_at` is updated best-effort.

The special scope `admin` grants access to `/inspector`, `/api/tokens`, and `/api/push-subscriptions`. It does **not** implicitly grant subscribe access — an admin token MUST also include the source name in its scopes to subscribe.
The special scope `admin` grants access to the inspector UI, `/api/tokens`, and `/api/push-subscriptions`. It does **not** implicitly grant subscribe access — an admin token MUST also include the source name in its scopes to subscribe.

### Ephemeral listener tokens

Expand Down Expand Up @@ -127,7 +127,7 @@ Excess requests return HTTP 429 with `Retry-After: <seconds>`. Buckets live in p

### Audit log

Every admin-meaningful action is recorded in the append-only `audit_events` table. Surfaced at `/inspector/audit` (admin only). Tracked actions:
Every admin-meaningful action is recorded in the append-only `audit_events` table. Surfaced at `/audit` (admin only). Tracked actions:

- `invite.create`, `invite.revoke`, `invite.consume`
- `user.create`, `user.deactivate`, `user.reactivate`, `user.role_change`, `user.update`, `user.password_reset`
Expand Down
8 changes: 4 additions & 4 deletions internal/admin/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ func TestDeactivate_CascadesTokensAndSubs(t *testing.T) {
}
}

// TestReactivate_DoesNotRestoreTokens covers task 9.10's "reactivation
// TestReactivate_DoesNotRestoreTokens covers consolidated "reactivation
// does not auto-restore tokens" requirement. Once a user is deactivated
// (cascading revoke + paused subs), reactivating clears deactivated_at
// only — every previously revoked token stays revoked, every paused
Expand Down Expand Up @@ -343,7 +343,7 @@ func TestReactivate_DoesNotRestoreTokens(t *testing.T) {
}
}

// TestPatchToken_OwnershipReflectedInMe covers task 9.10's "ownership
// TestPatchToken_OwnershipReflectedInMe covers consolidated "ownership
// transfer is reflected in /api/me calls by the new owner". After admin
// reassigns a token via PATCH /api/tokens/{id}, the previous owner's
// /api/me/tokens listing drops it and the new owner's listing gains it.
Expand Down Expand Up @@ -389,7 +389,7 @@ func TestPatchToken_OwnershipReflectedInMe(t *testing.T) {
}
}

// TestResetPassword_RejectsShortPasswords covers task 9.10's "password
// TestResetPassword_RejectsShortPasswords covers consolidated "password
// reset rejects short passwords". The admin endpoint runs the same
// ValidatePolicy as signup, so a short password yields 400.
func TestResetPassword_RejectsShortPasswords(t *testing.T) {
Expand Down Expand Up @@ -491,7 +491,7 @@ func TestListAudit_AdminOK_NonAdmin403(t *testing.T) {
}
}

// TestAuditCoverage_AdminActions (task 10.6): each representative admin
// TestAuditCoverage_AdminActions: each representative admin
// action produces exactly one audit_events row with the expected action,
// target_type, and target_id. The breadth here is intentional but bounded:
// we exercise three distinct actions per audit constant family
Expand Down
2 changes: 1 addition & 1 deletion internal/auth/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ func (m *Manager) DeleteSessionsByUser(ctx context.Context, userID string) error
// SetCookies is shared between login and the CSRF cookie rotation that
// happens on session creation: every call generates a fresh hooks_csrf
// value via secret.NewRandom, so a prior session's CSRF token cannot
// authenticate a freshly created session (task 4.3).
// authenticate a freshly created session.
func (m *Manager) SetCookies(w http.ResponseWriter, r *http.Request, cookieValue string) (csrfToken string, err error) {
csrf, err := secret.NewRandom()
if err != nil {
Expand Down
Loading
Loading