# Proxy Hooks

**Page:** foundation/proxy/hooks

[Download Raw Markdown](./foundation/proxy/hooks.md)

---

# Proxy Hooks

**Hooks let you run your own JavaScript on inbound traffic to any of your container services — before it reaches the service.** Use them for logging, auth gates, header transforms, payload scans, or "inspect-and-forward" patterns. Same cost, same deploy surface, same SDK as any other hoody-exec script.

Hooks are a tenant-owned MITM layer. Your hook script runs inside **your** `hoody-exec` (not the proxy), sees the real client IP, and can do anything a regular `hoody-exec` script can.

---

## When would I use a hook?

- **Audit log** — record every login attempt with user-agent + outcome, without modifying the login service.
- **Rate limit** — reject requests above a threshold before they hit an expensive backend.
- **Header transform** — strip sensitive headers from upstream responses, or add CSP headers uniformly.
- **Short-circuit** — return a cached or synthetic response without touching the real service.
- **Body scan** — reject uploads that fail antivirus before they land on disk.
- **Traffic mirroring** — fan out a copy of the request to a secondary analytics backend.

---

## How it works

```
Client ─► Hoody Proxy ──(match?)──► YES ──► your hoody-exec ──► your-hook-script.js
                                                                    │
                                                                    ├─ inspect / transform / short-circuit
                                                                    │
                                                                    └─ optional: forward to real upstream
                                                                                 (container-ip:service-port)
```

1. You add an entry to your container's proxy permissions file.
2. The entry says: "for service X, when the request matches these predicates, route it through *this* script in my hoody-exec."
3. On every matching request, the proxy dispatches to your hoody-exec with the original URL preserved. Your script runs, sees the request, and decides.
4. If you want to pass through to the real upstream, your script makes an HTTP call to `metadata.hook.upstream.host:port` (the authoritative address, pinned by the proxy).

The client IP is preserved end-to-end via TPROXY — your hook script can see `req.socket.remoteAddress` just like any other script.

---

## Minimal example: login audit

### 1. Add a hook in your proxy permissions

```json title="PATCH /api/v1/containers/<id>/proxy/permissions body"
{
  "project":   "<project-id>",
  "container": "<container-id>",
  "groups":    {},
  "permissions": {},
  "default":   "allow",
  "hooks": {
    "terminal": [
      {
        "match":   { "method": ["POST"], "path": "/api/login*" },
        "script":  { "path": "/login-audit" },
        "timeout": 500
      }
    ]
  }
}
```


The API requires `project`, `container`, `groups`, and `permissions` on every container permissions write — leaving `hooks` alone doesn't waive them. `groups` and `permissions` can be empty objects if you don't use group-based access control.


### 2. Deploy the hook script

```js title="login-audit.ts"
module.exports = async function (req, res, metadata, shared) {
  // Regular requests (no hook) have metadata.hook === undefined.
  if (!metadata.hook) {
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end(JSON.stringify({ mode: 'regular', url: req.url }));
    return;
  }

  const { auditId, origMethod, origPath } = metadata.hook;
  const clientIp = req.headers['x-real-ip'] ?? req.socket.remoteAddress;

  // Log the attempt.
  console.log(JSON.stringify({
    auditId, clientIp, method: origMethod, path: origPath,
    ua: req.headers['user-agent'] ?? null,
  }));

  // Forward to the real login backend. `metadata.hook.forward()` handles
  // URL assembly, RFC 7230 hop-by-hop stripping, multi-value Set-Cookie,
  // byte-transparent compression passthrough, client-abort propagation,
  // and sanitized 502 on upstream unavailability.
  await metadata.hook.forward(req, res);
};
```


The helper reuses the buffered `req.rawBody` that hoody-exec's body parser preserves. Add `// @rawBody` only when you need streaming semantics: bodies over `MAX_BODY_BYTES` (default 10 MB, configurable via hoody-exec's `--max-body-size` flag) or SSE-style client-streamed requests. Without `@rawBody`, anything over the cap returns 413 before your hook runs.


---

## Helper methods — `metadata.hook.forward()` / `.fetchUpstream()` / `.pipeResponse()`

When a hook dispatch is active, `metadata.hook` carries three non-enumerable helper methods that eliminate ~25 lines of hand-rolled fetch-and-pipe boilerplate.

### `forward(req, res, overrides?) → Promise<void>`

One-shot passthrough. Reads `req`, sends to the authoritative upstream, streams the response back into `res`. Handles:

- **URL assembly** from `metadata.hook.upstream.{host,port}` + `origPath` + client query string. Byte-preserving (no `new URL()` canonicalization).
- **Request-side hop-by-hop stripping** (RFC 7230 §6.1 base set + `Connection: <token>` extension). Client's `Host` header is preserved by default (override via `overrides.headers.host`).
- **Body source**: buffered `req.rawBody` when present, else `Readable.toWeb(req)` when `// @rawBody` is set. `content-length` rules enforced.
- **Response-side hop-by-hop stripping** and multi-value `Set-Cookie` preservation via `getSetCookie()`.
- **Compression**: always `Bun.fetch({ decompress: false })` — upstream bytes forward verbatim with `content-encoding` intact.
- **Client-abort propagation**: guarded wiring on `req.close` / `req.aborted` / `res.close` so SSE/long-poll disconnects abort the upstream fetch.
- **Error translation**: `network`/`abort`/`timeout` → sanitized 502 with `x-hoody-hook-audit` header. Programming errors (`invalid-override`/`no-body`/`body-consumed`/`duplex-unsupported`/`bytes-already-sent`) rethrow so the script author sees them as real bugs.

```js
// Transparent passthrough with logging
module.exports = async function (req, res, metadata) {
  if (!metadata.hook) { res.writeHead(404); res.end(); return; }
  console.log('hook', metadata.hook.auditId, metadata.hook.origMethod, metadata.hook.origPath);
  await metadata.hook.forward(req, res);
};
```

### `fetchUpstream(req, overrides?) → Promise`

Non-consuming fetch for inspect-then-forward. Returns a standard Fetch API `Response`; caller inspects/mutates/pipes. Does NOT write to `res`.

```js
// Auth gate — inspect upstream, rewrite response on reject
module.exports = async function (req, res, metadata) {
  if (!metadata.hook) { res.writeHead(404); res.end(); return; }
  try {
    const up = await metadata.hook.fetchUpstream(req);
    if (up.status === 401) {
      res.writeHead(401, { 'content-type': 'text/plain' });
      res.end('upstream rejected token'); return;
    }
    res.setHeader('x-hoody-hook-audit', metadata.hook.auditId);
    await metadata.hook.pipeResponse(up, res, { method: req.method });
  } catch (e) {
    if (e instanceof metadata.hook.HookUpstreamError) {
      res.writeHead(502); res.end('upstream unavailable');
    } else throw e;
  }
};
```

### `pipeResponse(upstream, res, { method? }?) → Promise<void>`

Piping half of `forward()` exposed for inspect-then-forward. Handles status, response-side hop-by-hop, multi-value `Set-Cookie`, `transfer-encoding`/`content-length` reconciliation (RFC 7230 §3.3.3), HEAD/204/304 no-body (pass `method: 'HEAD'` for HEAD suppression). Client-close during streaming is handled as silent cancellation. Mid-stream upstream errors throw `HookUpstreamError('stream-aborted')`.

### Overrides

```ts
interface HookUpstreamOverrides {
  method?: string;         // RFC 7230 token; wire case preserved
  pathAndQuery?: string;   // byte-preserving; rejects # / .. / \ / whitespace / control / absolute-form
  host?: string;           // IPv4 / DNS label; leading-zero octets rejected (octal-parse SSRF guard)
  port?: number;           // 1..65535
  headers?: Record<string, string | string[] | null>;  // `null` deletes; validates name + value
  body?: BodyInit | null;  // `null` drops body
  signal?: AbortSignal;    // merged with dispatcher abort + timeoutMs
  timeoutMs?: number;      // 1..86_400_000 (24h)
  onUpstreamError?: (err: HookUpstreamError) => { status; headers?; body?; };  // forward() only
}
```

### `HookUpstreamError`

User scripts run in the host realm via `new Function()`, so `HookUpstreamError` is NOT reachable as a free identifier. Use `metadata.hook.HookUpstreamError` for `instanceof` branching, or rely on the realm-independent surface: `err.name === 'HookUpstreamError'` + `err.kind`.

| `kind` | When it fires |
|---|---|
| `network` | Upstream TCP error (ECONNREFUSED, reset, DNS) |
| `abort` | Client-abort propagated via dispatcher signal or `overrides.signal` |
| `timeout` | `overrides.timeoutMs` fired (Bun `TimeoutError` properly classified) |
| `invalid-override` | Bad `host` / `port` / `pathAndQuery` / `method` / headers / `timeoutMs` / `signal` / `onUpstreamError` |
| `bytes-already-sent` | `pipeResponse` called with `res.headersSent` already true |
| `stream-aborted` | Upstream read failed mid-stream (while client still connected) |
| `no-body` | Non-`@rawBody` script consumed `req` without preserving `rawBody`, or pre.js drained the `@rawBody` stream |
| `body-consumed` | `fetchUpstream` called twice on a streamed `@rawBody` without `overrides.body` |
| `duplex-unsupported` | Runtime rejected `duplex: 'half'` (Bun < 1.3) |

---

## Matrix entry reference

### `hooks.<service>` — per-service array (max 8 per service, 32 per file)

```json
{
  "hooks": {
    "terminal": [ { ... }, { ... } ],
    "files":    [ { ... } ],
    "exec":     [ { ... } ]
  }
}
```

Allowed services are the tenant-reachable ones your hoody-kit exposes via SNI — in current builds that's: `terminal`, `files`, `notes`, `run`, `curl`, `watch`, `cron`, `pipe`, `sqlite`, `browser`, `notifications`, `tunnel`, `daemon`, `code`, `display`, `exec`. Custom hostname aliases resolve to one of these services at the proxy edge; they are not a separate dispatchable surface. Reject-listed: `logs`, `proxy`, `workspaces` (internal infrastructure — the API refuses to persist hooks for them, and the proxy additionally refuses to dispatch them even if an on-disk matrix file is tampered to include them).

### `match` — predicate (AND-joined)

```json
{
  "match": {
    "method":  ["POST", "PUT"],            // or "*" for any-except-OPTIONS; single string also OK
    "path":    "/api/*",                    // glob: `*` matches one segment; trailing `*` matches any suffix
    "headers": { "X-Tenant": "alice" }      // optional: all keys must match exactly (keys case-insensitive)
  }
}
```

- `method` defaults to `"*"`. `"*"` does NOT match `OPTIONS` — CORS preflight is skipped by default. Include `OPTIONS` explicitly if you want to hook preflights.
- `path` defaults to `"*"` (any).
- Requests with `Upgrade: websocket` are never hook-routed — WebSocket and hooks are mutually exclusive.
- Rules evaluate in order. **First match wins.**

### `script` — target (one of your hoody-exec scripts)

```json
{
  "script": {
    "subdomain": "myapp",       // optional, LOWERCASE alphanum 1-64 chars: /^[a-z0-9]{1,64}$/ — or "default"
    "execId":    "obs",         // optional, LOWERCASE alphanum 1-64 chars: /^[a-z0-9]{1,64}$/
    "path":      "/login-audit" // required, exact path matching /^\/[A-Za-z0-9._\-\/]{0,256}$/ — uppercase allowed; no `..` or `.` dot-segments, no `//`, no NUL, no wildcards, no percent-encoding
  }
}
```

Coordinates match your hoody-exec script addressing. A hoody-exec script's public URL has hostname `[<subdomain>.]<projectId>-<containerId>[-exec-<execId>].<node>.<domain>` with the script path served at `<path>` — both the `<subdomain>.` prefix and the `-exec-<execId>` segment are optional. The three `script` fields map to the same coordinates: `subdomain` (optional), `execId` (optional), `path` (required). Specifically:

- `subdomain` and `execId` are **lowercase alphanum only** (to match the public SNI parser which lowercases the hostname before matching).
- `script.path` **allows** uppercase ASCII letters but **rejects** `*`, `%`, spaces, `:`, non-ASCII, and path-traversal segments.
- `match.path` is a glob (allows `*`) and has its own charset — see below.


If your hook target script declares a `// @token` directive, the same token gate runs for hook invocations as for regular requests. A hook request without the token gets `401` before your hook logic runs. If you want the hook dispatch to bypass the token check, point `script.path` at a different (token-less) script, or handle the token inside a router.


### `timeout` — soft client-visible deadline

```json
{ "timeout": 500 }
```

Milliseconds, clamped to `[1, 30000]`. Default **500 ms**. This is a soft, client-visible deadline enforced inside hoody-exec — it sits well inside nginx's much-longer upstream read timeout (`proxy_read_timeout 86400s` in the current edge config), so the hook deadline always fires first. The main reason to keep it tight is tenant UX: a 30s timeout makes a slow hook feel like a broken service.

**What the client sees when the deadline fires depends on whether your script has already started writing:**

- If headers haven't been sent → client gets `504 hook timeout` (JSON body).
- If headers/body streaming has started → hoody-exec destroys the response socket. The client sees a truncated response or a connection reset. There is no trailing 504 — the TCP-level abort is the signal.

**What happens to your script when the deadline fires:**

JavaScript execution is NOT cancelled. Your script keeps running until it naturally returns. When it later tries to `res.write`/`res.end` on the destroyed socket, those calls error out (swallowed by hoody-exec so the worker stays healthy). The practical effect: long-running I/O you kicked off (e.g. an in-flight `await fetch(...)`) proceeds to completion and its response is discarded. Don't rely on cooperative cancellation at the deadline — design your hook to fit well inside the timeout.

---

## What your hook script sees

When invoked as a hook, `metadata.hook` is populated:

```ts
metadata.hook = {
  auditId:    "550e8400-e29b-41d4-a716-446655440000",  // UUID, or "none" if audit rate-limited OR audit-gate blocked OR DB write failed
  origMethod: "POST",                // client's original method
  origPath:   "/api/login",          // client's original path (query stripped)
  service:    "terminal",            // the service the client targeted
  upstream:   {
    host: "192.168.1.42",            // container IP — authoritative HOST for forwarding
    port: 76                         // REAL service port (not hoody-exec's)
  }
};
```

`upstream.host:port` gives you the **host and port** of the real service. It does NOT carry the full routing envelope that non-hook requests would resolve to — things like service-specific query arg injection, path rewrites, or explicit `https` protocol are NOT propagated into `metadata.hook.upstream`. Use it for the common case of "forward the request as-is to the real service"; if your hook needs to replicate the full non-hook routing, fetch the service's oracle response yourself or forward via an internal endpoint your tenant owns.

For a non-hook invocation of the same script, `metadata.hook` is `undefined` — one script can serve both regular traffic and hook dispatches.

`req.url` is the client's original URL (path + query). `req.headers` is the client's headers with `X-Hoody-Hook-*` stripped — but note the edge proxy also injects/rewrites the standard forwarding headers (`Host`, `X-Real-IP`, `X-Forwarded-For`, `X-Forwarded-Proto`) the same way it does for non-hook traffic. Use `X-Real-IP` for the real client IP.

---

## Security model

- **Hook scripts run in your own `hoody-exec`.** They see only your tenant's traffic. The proxy cannot dispatch another tenant's request to your hook.
- **Your script runs with tenant privileges.** A hook can't do anything a `hoody-exec` script couldn't already do — there's no sandbox escape risk beyond what already exists.
- **The proxy prevents metadata forgery.** Clients cannot forge `X-Hoody-Hook-*` headers — they are swept by the proxy before the request reaches `hoody-exec`, even on direct traffic to the `exec` service.
- **Fail-closed by default.** A hook error (exception, timeout, 404 on the target script) returns an error to the client — there is no `fail-open` fallback. If you want "optional" hooks, catch errors inside your script and return success.
- **Audit trail is best-effort.** The proxy tries to write one audit row per matched hook dispatch with `op: hook-dispatch`, carrying `projectId`, `containerId`, `groupName`, `service`, `scriptRef`, `origMethod`, `origPath`. The hook still dispatches (with `metadata.hook.auditId === 'none'`) if the audit subsystem can't write the row — specifically on (a) the per-scope SNI rate-limit bucket being exhausted, (b) the audit-gate being in a `blocked` state, or (c) a DB write exception. Audit is not on the hot-path gating routing. Don't rely on the audit log as a source of truth for every hook invocation.

---

## Limitations (v1)

- **Container-level only.** Project-level `hooks` is rejected by the API.
- **WebSocket upgrades skip hooks.** Use a REST endpoint if you need hooking.
- **Long-lived SSE/streaming** — bounded by the hook timeout (max 30s). Streams that outlive the timeout get truncated.
- **Hard timeout, no JS cancellation.** At the deadline the client sees a 504 (if headers haven't been sent yet) or a truncated/reset response (if headers are already flying). Your script itself keeps running until it naturally completes — its response just gets discarded. Cooperate with the deadline by yielding.
- **Soft cap of 8 hooks per service, 32 per file.** Design for first-match-wins; don't rely on iterating many fine-grained rules.

---

## Recursion — don't fetch the public domain

Your hook forwards via `metadata.hook.upstream.host:port` — the container IP, directly. **Never** fetch the public SNI (`https://<projectId>-<containerId>-terminal-1.<domain>/...`) from inside a hook — that routes back through the proxy and can re-trigger the hook, potentially infinitely.

The proxy enforces a timeout budget on the outermost client request, so unbounded recursion is self-limiting, but it's still a waste of container resources. Use `metadata.hook.upstream` as the only upstream address.

---

## API surface

Hooks are first-class resources with their own CRUD endpoints. You can also manage them in bulk by writing the permissions document, which embeds `hooks` as a field.

### Dedicated hook endpoints

| Verb | Path | What it does |
|---|---|---|
| `GET`    | `/api/v1/containers/{id}/proxy/hooks`                             | List all hooks grouped by service |
| `GET`    | `/api/v1/containers/{id}/proxy/hooks/{service}`                   | List hooks for one service |
| `POST`   | `/api/v1/containers/{id}/proxy/hooks/{service}`                   | Append or insert a hook |
| `DELETE` | `/api/v1/containers/{id}/proxy/hooks/{service}`                   | Clear all hooks for a service |
| `GET`    | `/api/v1/containers/{id}/proxy/hooks/{service}/{hookId}`          | Get a single hook |
| `PATCH`  | `/api/v1/containers/{id}/proxy/hooks/{service}/{hookId}`          | Replace a hook in place |
| `DELETE` | `/api/v1/containers/{id}/proxy/hooks/{service}/{hookId}`          | Remove a hook |
| `PATCH`  | `/api/v1/containers/{id}/proxy/hooks/{service}/{hookId}/position` | Move a hook to a new position |

All mutating endpoints require `If-Match: file:v` (ETag from the last read) for optimistic concurrency.

### Via the Hoody CLI

Read-only calls don't need an ETag. Mutating calls (`create`, `update`, `delete`, `clear-service`, `move`) require `--if-match file:v` — fetch the current version with `list` or `get` first.

Add a single hook:

```bash
hoody containers proxy hooks create <container-id> terminal \
    --match '{"method":["POST"],"path":"/api/login*"}' \
    --script '{"path":"/login-audit"}' \
    --timeout 500 \
    --if-match file:v1
```

List, get, update, move, delete:

```bash
hoody containers proxy hooks list <container-id>
hoody containers proxy hooks list-service <container-id> terminal
hoody containers proxy hooks get <container-id> terminal <hook-id>
hoody containers proxy hooks update <container-id> terminal <hook-id> \
    --match '{"method":["POST","PUT"],"path":"/api/login*"}' \
    --script '{"path":"/login-audit"}' \
    --timeout 1000 \
    --if-match file:v2
hoody containers proxy hooks move <container-id> terminal <hook-id> --position 0 --if-match file:v3
hoody containers proxy hooks delete <container-id> terminal <hook-id> --if-match file:v4
hoody containers proxy hooks clear-service <container-id> terminal --if-match file:v5
```

Bulk-write the whole permissions document (hooks inline):

```bash
hoody containers proxy permissions replace <container-id> \
    --default allow \
    --hooks '{"terminal":[{"match":{"method":["POST"]},"script":{"path":"/login-audit"}}]}' \
    --if-match file:v5
```

### Via the SDK (TypeScript)

Targeted hook CRUD (methods live under `client.api.proxyHooks`):

```ts
// Add a hook
await client.api.proxyHooks.addContainerProxyHook(containerId, 'terminal', {
  match:   { method: ["POST"], path: "/api/login*" },
  script:  { path: "/login-audit" },
  timeout: 500,
}, { ifMatch: 'file:v1' });

// List hooks for a service
const { data } = await client.api.proxyHooks.listContainerProxyServiceHooks(containerId, 'terminal');

// Move a hook to the front
await client.api.proxyHooks.moveContainerProxyHook(containerId, 'terminal', hookId, { position: 0 }, { ifMatch: 'file:v2' });
```

Bulk replace via the permissions document:

```ts
await client.api.proxyPermissionsContainer.replace(containerId, {
  project: projectId,
  container: containerId,
  groups: {},
  permissions: {},
  default: "allow",
  hooks: {
    terminal: [
      {
        match:   { method: ["POST"], path: "/api/login*" },
        script:  { path: "/login-audit" },
        timeout: 500,
      },
    ],
  },
}, { ifMatch: 'file:v5' });
```

---

## Further reading

- [Proxy Permissions](/foundation/proxy/permissions/) — the file your hooks live in
- [API: Script Management](/api/exec/script-management/) — how to deploy the script your hook references
- [API: Proxy Permissions](/api/proxy-permissions/) — full endpoint reference


Hook requests look normal in logs — the URL is the client's original URL, not a synthetic `/hooks/...` route. Check `metadata.hook != null` inside your script to know when it's invoked as a hook. Use `metadata.hook.auditId` as your correlation key for tracing a dispatch.