Proxy Hooks
Section titled “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. A hook script has the same cost, deploy surface, and SDK as any other hoody-exec script.
Hooks are a tenant-owned MITM layer. Your hook script runs inside your own hoody-exec (not the proxy), sees the real client IP, and can do anything a regular hoody-exec script can.
Common uses
Section titled “Common uses”- Identity gate: require a valid Hoody login (a signed identity claim) before your app is reachable. See the full recipe below.
- Audit log: record every login attempt with user-agent and 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
Section titled “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)- You create a hook through the dedicated
/api/v1/containers/{id}/proxy/hooks/{service}endpoint; the server assigns its ID and stores it in the container’s proxy-permissions document. - The entry says: “for service X, when the request matches these predicates, route it through this script in my hoody-exec.”
- On every matching request, the proxy dispatches to your hoody-exec with the original URL preserved. Your script runs, sees the request, and decides.
- 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).
TPROXY preserves the client IP end to end; your hook script can read req.socket.remoteAddress like any other script.
Login audit example
Section titled “Login audit example”Step 1: Add a hook
Section titled “Step 1: Add a hook”You create hooks one at a time on the dedicated hooks endpoint, which assigns each rule a stable server-side id:
{ "match": { "method": ["POST"], "path": "/api/login*" }, "script": { "path": "/login-audit" }, "timeout": 500}Step 2: Deploy the hook script
Section titled “Step 2: Deploy the hook script”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);};Helper methods
Section titled “Helper methods”When a hook dispatch is active, metadata.hook carries three non-enumerable helper methods, forward(), fetchUpstream(), and pipeResponse(), which handle the fetch-and-pipe plumbing you would otherwise write by hand.
forward(req, res, overrides?) → Promise<void>
Section titled “forward(req, res, overrides?) → Promise<void>”forward() is a one-shot passthrough: it reads req, sends to the authoritative upstream, and streams the response back into res. It handles:
- URL assembly from
metadata.hook.upstream.{host,port}+origPath+ client query string. Byte-preserving (nonew URL()canonicalization). - Request-side hop-by-hop stripping (RFC 7230 §6.1 base set +
Connection: <token>extension). The client’sHostheader is preserved by default (override viaoverrides.headers.host). - Body source: buffered
req.rawBodywhen present, elseReadable.toWeb(req)when// @rawBodyis set.content-lengthrules enforced. - Response-side hop-by-hop stripping and multi-value
Set-Cookiepreservation viagetSetCookie(). - Compression: always
Bun.fetch({ decompress: false }); upstream bytes forward verbatim withcontent-encodingintact. - Client-abort propagation: guarded wiring on
req.close/req.aborted/res.closeso SSE/long-poll disconnects abort the upstream fetch. - Error translation:
network/abort/timeoutbecome a sanitized 502 with anx-hoody-hook-auditheader. Programming errors (invalid-override/no-body/body-consumed/duplex-unsupported/bytes-already-sent) rethrow so the script author sees them as real bugs.
// Transparent passthrough with loggingmodule.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<Response>
Section titled “fetchUpstream(req, overrides?) → Promise<Response>”fetchUpstream() is a non-consuming fetch for inspect-then-forward: it returns a standard Fetch API Response for the caller to inspect, mutate, or pipe. It does not write to res.
// Auth gate: inspect upstream, rewrite response on rejectmodule.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>
Section titled “pipeResponse(upstream, res, { method? }?) → Promise<void>”pipeResponse() is the piping half of forward(), exposed for inspect-then-forward. It handles status, response-side hop-by-hop stripping, multi-value Set-Cookie, transfer-encoding/content-length reconciliation (RFC 7230 §3.3.3), and HEAD/204/304 no-body handling (pass method: 'HEAD' for HEAD suppression). The helper treats a client close during streaming as silent cancellation. Mid-stream upstream errors throw HookUpstreamError('stream-aborted').
Overrides
Section titled “Overrides”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
Section titled “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' plus 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) |
Identity-claim auth gate
Section titled “Identity-claim auth gate”Gate any service on “a valid Hoody user” with custom logic. Hoody mints identity claims: ED25519-signed credentials proving that Hoody authenticated the user. For the common case, prefer the native hoody-identity permission group, which takes one permission-file entry and no code, and injects verified X-Hoody-Identity-* headers for you. Reach for the hook recipe below when you need what the native gate doesn’t do: browser-facing apps (cookie transport, since browsers can’t attach custom headers on navigation), custom rejection responses, per-path logic, or claim verification combined with other checks.
The pattern works like this: the client sends its claim as <payload_b64>.<signature_hex> in an x-hoody-claim header. The header sits deliberately outside the reserved x-hoody-identity-* namespace, which the edge unconditionally strips before traffic reaches any upstream, including your hoody-exec hook; a claim sent in the reserved namespace would never arrive. The hook verifies the signature and payload, rejects with 401 on any failure, and forwards with trusted x-hoody-user-* headers your app can rely on.
Step 1: Add a catch-all hook
Section titled “Step 1: Add a catch-all hook”The entry is a catch-all match on your app’s service:
{ "match": { "method": "*", "path": "*" }, "script": { "path": "/identity-gate" }, "timeout": 3000}Step 2: Deploy the gate script
Section titled “Step 2: Deploy the gate script”const { createPublicKey, verify } = require('crypto');
// Pin Hoody's key at deploy time: GET https://api.hoody.com/api/v1/meta/public-key// Re-pin when that endpoint's active_kid rotates; until you do, newly issued// claims fail verification and this gate returns 401 for everyone (fail-closed).const HOODY_PUBLIC_KEY_HEX = '8c8d683c125761bd9157e3a6f98c30d81cd7f2be4d16062a8342d1fcd2ca474a';const HOODY_KID = 'v1';const SPKI_PREFIX = '302a300506032b6570032100'; // DER envelope for a raw ed25519 key
const HOODY_KEY = createPublicKey({ key: Buffer.from(SPKI_PREFIX + HOODY_PUBLIC_KEY_HEX, 'hex'), format: 'der', type: 'spki',});
function verifyClaim(headerValue) { const [payload_b64, signature_hex] = String(headerValue ?? '').split('.'); if (!payload_b64 || !/^[0-9a-f]{128}$/.test(signature_hex ?? '')) return null; if (!verify(null, Buffer.from(payload_b64, 'utf8'), HOODY_KEY, Buffer.from(signature_hex, 'hex'))) return null; let p; try { p = JSON.parse(Buffer.from(payload_b64, 'base64url').toString('utf8')); } catch { return null; } const now = Math.floor(Date.now() / 1000); const ok = p.claim_type === 'identity' && p.iss === 'hoody-api' && p.exp > now && p.exp > p.iat && p.iat <= now + 300 && p.kid === HOODY_KID; return ok ? p : null;}
module.exports = async function (req, res, metadata) { if (!metadata.hook) { res.writeHead(404); res.end(); return; }
const user = verifyClaim(req.headers['x-hoody-claim']); if (!user) { res.writeHead(401, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'HOODY_IDENTITY_REQUIRED', message: 'Send a valid identity claim as x-hoody-claim: <payload_b64>.<signature_hex>', })); return; }
await metadata.hook.forward(req, res, { headers: { 'x-hoody-claim': null, // don't leak the credential to the app 'x-hoody-user': user.username, // trusted: set by this hook from the verified payload, 'x-hoody-user-id': user.sub, // replacing anything the client sent 'x-hoody-user-type': user.type, // "user" | "admin" }, });};Gaps to know
Section titled “Gaps to know”- WebSocket upgrades: they never hit hooks, so this recipe does not gate WebSocket endpoints. Keep WebSocket surfaces off the gated service, or verify the claim inside the app for those paths.
- CORS preflights:
method: "*"skipsOPTIONS, so preflights reach your app ungated. Harmless if your app does no work onOPTIONS; addOPTIONStomatch.methodto gate preflights too. - Header trust scope: the identity headers are trustworthy only on hook-matched traffic. With the catch-all match above, every non-WebSocket, non-
OPTIONSrequest is verified. If you narrow thematch, requests outside it arrive with whatever headers the client sent. - Direct ingress: the gate covers only traffic that flows through the Hoody Proxy hook layer. Your app’s real
container-ip:portexists independently of the proxied hostname; anything that reaches it directly (for example, from another process or container that can route to it) bypasses the hook entirely and can send arbitraryx-hoody-user-*headers. Restrict direct ingress with the Container Firewall, and never trust these headers on a listener that isn’t guaranteed to receive only hook-forwarded traffic. - Authorization: “valid Hoody user” means any Hoody user. The claim proves Hoody authenticated someone, not that they belong to your project. If you need “my users only”, authorize
user.subagainst your own allowlist after verification. - Expiry: claims expire (30-day default). A
401from this gate means the client should log in to Hoody again to obtain a fresh claim.
Matrix entry reference
Section titled “Matrix entry reference”hooks.<service>
Section titled “hooks.<service>”Each service key holds an ordered array of hook rules: at most 8 per service and 32 per file.
{ "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, agent, desktop, and d-tcp. That list is descriptive rather than normative: the API validates the service name by shape and then applies the reject list below, so a newly added service becomes hookable without a doc change. 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, cdp (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 is a predicate; its fields are AND-joined.
{ "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) }}methoddefaults to"*", which does not matchOPTIONS; CORS preflight is skipped by default. IncludeOPTIONSexplicitly if you want to hook preflights.pathdefaults to"*"(any).- Requests with
Upgrade: websocketare never hook-routed; WebSocket and hooks are mutually exclusive. - Rules evaluate in order; the first match wins.
script
Section titled “script”script targets one of your hoody-exec scripts.
{ "script": { "subdomain": "myapp", // optional, lowercase alphanum 1-64 chars or "default": /^(default|[a-z0-9]{1,64})$/ "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:
subdomainandexecIdare lowercase alphanum only (to match the public SNI parser, which lowercases the hostname before matching).script.pathallows uppercase ASCII letters but rejects*,%, spaces,:, non-ASCII, and path-traversal segments.match.pathis a glob (allows*) and has its own charset (see below).
timeout
Section titled “timeout”timeout is a soft, client-visible deadline enforced inside hoody-exec.
{ "timeout": 500 }Milliseconds, clamped to [1, 30000]; the default is 500 ms. The deadline 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, the client gets
504 hook timeout(JSON body). - If headers or 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.
The deadline does not cancel JavaScript execution. 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 (for example 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
Section titled “What your hook script sees”When invoked as a hook, metadata.hook is populated:
metadata.hook = { auditId: "550e8400-e29b-41d4-a716-446655440000", // UUID, or "none" if audit rate-limited, 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; the authoritative host for forwarding port: 76 // real service port (not hoody-exec's) }};upstream.host:port is the host and port of the real service. It does not carry the full routing envelope that non-hook requests would resolve to: service-specific query arg injection, path rewrites, and 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. Note that the edge proxy also injects or 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
Section titled “Security model”- Tenant isolation: hook scripts run in your own
hoody-execand see only your tenant’s traffic. The proxy cannot dispatch another tenant’s request to your hook. - Tenant privileges: your script runs with tenant privileges. A hook can’t do anything a
hoody-execscript couldn’t already do; there’s no sandbox escape risk beyond what already exists. - Metadata forgery: clients cannot forge
X-Hoody-Hook-*headers. The proxy sweeps them before the request reacheshoody-exec, even on direct traffic to theexecservice. - 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.
- Best-effort audit trail: the proxy tries to write one audit row per matched hook dispatch with
op: hook-dispatch, carryingprojectId,containerId,groupName,service,scriptRef,origMethod,origPath. The hook still dispatches (withmetadata.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 ablockedstate, or (c) a DB write exception. Audit sits off the hot path and never gates routing. Don’t rely on the audit log as a source of truth for every hook invocation.
Limitations (v1)
Section titled “Limitations (v1)”- Container level only. The API rejects project-level
hooks. - No WebSocket hooks. Upgrades skip the hook layer; use a REST endpoint if you need hooking.
- Bounded streaming. Long-lived SSE and streaming responses are 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 or reset response (if headers are already in flight). Your script keeps running until it naturally completes; its response is discarded. Cooperate with the deadline by yielding.
- Soft caps. At most 8 hooks per service and 32 per file. Design for first-match-wins; don’t rely on iterating many fine-grained rules.
Recursion
Section titled “Recursion”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 still wastes container resources. Use metadata.hook.upstream as the only upstream address.
API surface
Section titled “API surface”Hooks are first-class resources with their own CRUD endpoints. Although hooks are stored in the permissions document, manage them only through the dedicated hooks endpoints; bulk permissions writes cannot carry hooks today.
Dedicated hook endpoints
Section titled “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 |
PUT | /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<N> (ETag from the last read) for optimistic concurrency.
Hoody CLI
Section titled “Hoody CLI”Read-only calls don’t need an ETag. Mutating calls (create, update, delete, clear-service, move) require --if-match file:v<N>; fetch the current version with list or get first.
Add a single hook:
hoody containers proxy hooks create <container-id> terminal \ --match-method POST \ --match-path '/api/login*' \ --script-path /login-audit \ --timeout 500 \ --if-match file:v1List, get, update, move, delete:
hoody containers proxy hooks list <container-id>hoody containers proxy hooks list-service <container-id> terminalhoody containers proxy hooks get <container-id> terminal <hook-id>hoody containers proxy hooks update <container-id> terminal <hook-id> \ --match-method POST,PUT \ --match-path '/api/login*' \ --script-path /login-audit \ --timeout 1000 \ --if-match file:v2hoody containers proxy hooks move <container-id> terminal <hook-id> --position 0 --if-match file:v3hoody containers proxy hooks delete <container-id> terminal <hook-id> --if-match file:v4 -yhoody containers proxy hooks clear-service <container-id> terminal --if-match file:v5hooks delete is the only hook command that prompts for confirmation: without the global -y/--yes it blocks on a y/N question, so scripts and agents must pass it.
TypeScript SDK
Section titled “TypeScript SDK”Targeted hook CRUD (methods live under client.api.proxyHooks):
// Add a hookawait 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 serviceconst { data } = await client.api.proxyHooks.listContainerProxyServiceHooks(containerId, 'terminal');
// Move a hook to the frontawait client.api.proxyHooks.moveContainerProxyHook(containerId, 'terminal', hookId, { position: 0 }, { ifMatch: 'file:v2' });
// Remove a hookawait client.api.proxyHooks.removeContainerProxyHook(containerId, 'terminal', hookId, { ifMatch: 'file:v3' });client.api.proxyPermissionsContainer.replace() cannot carry hooks: the permissions validator requires each rule’s server-assigned id, and the endpoint’s body schema rejects id as an unknown property. Route every hook write through client.api.proxyHooks.
Further reading
Section titled “Further reading”- Proxy Permissions: the file your hooks live in
- API: Script Management: how to deploy the script your hook references
- API: Proxy Permissions: full endpoint reference