feat(auth): optional WEBTERM_TOKEN access-token gate (W5)

An OPTIONAL shared token so the app can be used off-LAN (via the relay/tunnel) more
safely than "anyone who reaches the port gets a shell". Sits IN FRONT OF the existing
Origin/CSRF model — never replacing it — and is fully inert when unset.

- src/http/auth.ts (new, pure): constantTimeEqual hashes both inputs to sha256 (32
  bytes) then crypto.timingSafeEqual — no `===`, no length side-channel, never throws.
  parseCookieHeader / cookieIsAuthed / buildSetCookie / isAuthEnabled.
- WEBTERM_TOKEN in config: 16–512 URL/cookie-safe chars or the server refuses to start.
- GET /?token=<t> or POST /auth (rate-limited 10/min) validates → sets
  HttpOnly; SameSite=Strict; Secure-when-https cookie; public/login.html (no <script>).
- When enabled: the WS handshake (AFTER the Origin check, before handleUpgrade) + a
  central authGate over all remote HTTP require the cookie. Open: /login, /auth.
  Loopback bypass scoped to /hook* ONLY (tighter than the plan — gates the local
  browser too).
- Unset ⇒ isAuthEnabled false ⇒ pure passthrough (LAN zero-config unchanged).

Honest tradeoff (in code + CLAUDE.md + login page): a bar-raiser, NOT a TLS/Tailscale
substitute — on bare ws:// the token is cleartext + replayable; only hardens the
TLS-terminated relay path. Verified: typecheck + build:web clean, 2118 pass at
--test-timeout=30000 (disabled-mode regression proven; only the known tmux flake red);
auth.ts 100% line coverage.
This commit is contained in:
Yaojia Wang
2026-07-13 05:25:07 +02:00
parent 9683a16f4f
commit 469037cb94
10 changed files with 995 additions and 8 deletions

View File

@@ -65,6 +65,8 @@ npm test # unit tests (vitest, all modules)
Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `USE_TMUX` (1/0/auto), `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST``0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1.
`WEBTERM_TOKEN` (w5-access-token, optional) — a shared access token that gates the WS handshake (alongside, not replacing, the Origin check) and every remote HTTP route. **Unset ⇒ auth disabled**, so LAN zero-config is preserved exactly as before; only when set does the gate activate. When set it must be 16512 URL/cookie-safe chars (`[A-Za-z0-9._~+/=-]`) or the server refuses to start. Deliver it once via `GET /?token=<t>` (or `POST /auth`), which sets an `HttpOnly; SameSite=Strict; Secure-when-https` cookie the browser auto-sends thereafter; loopback hook ingest (`/hook*`) is exempt so the smart-features side-channel keeps working. **Honest tradeoff:** it is a bar-raiser, **not** a TLS/Tailscale substitute — on bare `ws://` the token travels in cleartext and is replayable by a LAN sniffer; it only meaningfully hardens the relay/tunnel (TLS-terminated) path. Never port-forward the raw port to the internet. See `src/http/auth.ts` and `docs/plans/w5-access-token.md`.
## Architecture (the parts that span files)
The server is a **byte-shuttle, not a terminal**. It does not parse ANSI/terminal semantics — xterm.js (browser) interprets escape sequences and renders; node-pty (server) provides the pseudo-terminal so the shell believes it has a real TTY. This separation is the central simplification — keep it. Don't add terminal-semantic parsing on the server.

View File

@@ -121,12 +121,15 @@ build sequence (dependencies noted).
the split-grid watch board + statusLine gauges + per-quadrant inline approve; server side is a
thin "sessions grouped by repo/worktree" endpoint. Cost is UI. **Effort: L.** Depends on Wave 4.
- [ ] **App-level access token** (leave-the-LAN bar-raiser)
Smallest step to safely use the new relay/tunnel off-LAN: a constant-time-compared
`WEBTERM_TOKEN` checked on the WS handshake (alongside Origin) + on state-changing routes,
set as an httpOnly cookie after one-time `?token=`, disabled when unset (keeps LAN zero-config).
**Honest tradeoff:** a bar-raiser, **not** a TLS/Tailscale substitute; needs constant-time
compare + cookie flags + rate-limiting or it's security theater. **Effort: M.**
- [x] **App-level access token** (leave-the-LAN bar-raiser) — shipped on `develop`
A constant-time-compared (`crypto.timingSafeEqual` over SHA-256, fixed-length guard)
`WEBTERM_TOKEN` checked on the WS handshake (alongside, not replacing, Origin) + a central
gate over every remote HTTP route, set as an `HttpOnly; SameSite=Strict; Secure-when-https`
cookie after one-time `GET /?token=` or `POST /auth` (rate-limited 10/min/IP), disabled when
unset (keeps LAN zero-config byte-identical). Charset/length-validated at load; loopback
`/hook*` ingest exempt. `src/http/auth.ts` + wiring in `src/server.ts`.
**Honest tradeoff:** a bar-raiser, **not** a TLS/Tailscale substitute — on bare `ws://` the
token is cleartext and replayable; only hardens the TLS-terminated relay/tunnel path. **Effort: M.**
- [ ] **Android Projects / Diff / Worktree screens** (client parity)
Android's whole v0.6/v0.7 projects-git UI is SDK-gated/off in `settings.gradle.kts`.

125
public/login.html Normal file
View File

@@ -0,0 +1,125 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Sign in — web-terminal</title>
<!--
w5-access-token login page. FULLY SELF-CONTAINED: inline <style> only, NO
<script> (the CSP is `script-src 'self'`, which blocks inline JS). Auth is a
native form POST → the server 302s and sets the HttpOnly cookie → the app
loads. Zero JS required. The error banner is revealed server-side by GET
/login?e=1 swapping the single `ERRSTATE` token on <body> for `show-error`.
-->
<style>
:root {
color-scheme: light dark;
--bg: #0b0e14;
--card: #131722;
--fg: #e6e9ef;
--muted: #8b93a7;
--accent: #4c8bf5;
--border: #262c3a;
--err-bg: #3a1720;
--err-fg: #ff9aa8;
--err-border: #6b2230;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f4f6fb;
--card: #ffffff;
--fg: #1a1f2b;
--muted: #5a6274;
--accent: #2563eb;
--border: #dde2ec;
--err-bg: #fdecef;
--err-fg: #b42035;
--err-border: #f3c2ca;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: var(--bg);
color: var(--fg);
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.card {
width: 100%;
max-width: 22rem;
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1.75rem;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.28);
}
h1 { margin: 0 0 0.25rem; font-size: 1.15rem; }
p.sub { margin: 0 0 1.25rem; color: var(--muted); font-size: 0.85rem; }
label { display: block; margin-bottom: 0.4rem; font-size: 0.8rem; color: var(--muted); }
input[type="password"] {
width: 100%;
padding: 0.65rem 0.75rem;
font-size: 1rem;
color: var(--fg);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 9px;
outline: none;
}
input[type="password"]:focus { border-color: var(--accent); }
button {
margin-top: 1rem;
width: 100%;
padding: 0.7rem;
font-size: 0.95rem;
font-weight: 600;
color: #fff;
background: var(--accent);
border: 0;
border-radius: 9px;
cursor: pointer;
}
button:hover { filter: brightness(1.06); }
.error-banner {
display: none;
margin-bottom: 1rem;
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
color: var(--err-fg);
background: var(--err-bg);
border: 1px solid var(--err-border);
border-radius: 9px;
}
body.show-error .error-banner { display: block; }
.note {
margin-top: 1.25rem;
font-size: 0.72rem;
line-height: 1.45;
color: var(--muted);
}
</style>
</head>
<body class="login ERRSTATE">
<main class="card">
<h1>Access token required</h1>
<p class="sub">This web-terminal is protected by a shared access token.</p>
<div class="error-banner" role="alert">Invalid token — please try again.</div>
<form method="POST" action="/auth">
<label for="token">Access token</label>
<input id="token" name="token" type="password" autocomplete="current-password" autofocus required />
<button type="submit">Unlock</button>
</form>
<p class="note">
Bar-raiser, not a substitute for TLS/Tailscale. On a plain <code>ws://</code> LAN
the token is sent in cleartext and can be replayed — only use this off-LAN over
an HTTPS/WSS relay or tunnel. Never port-forward the raw port to the internet.
</p>
</main>
</body>
</html>

View File

@@ -169,6 +169,31 @@ function parseProjectRoots(raw: string | undefined, homeDir: string): readonly s
return Object.freeze(roots.length > 0 ? roots : [homeDir])
}
/**
* WEBTERM_TOKEN charset+length policy (w5-access-token). URL/cookie-safe chars
* only (no `;`, space, `%`, or control chars) so the value cannot inject a
* Set-Cookie/header split or create query-string `%`-encoding ambiguity; the
* ≥16 floor multiplies brute-force cost against the /auth rate limiter.
*/
const WEBTERM_TOKEN_RE = /^[A-Za-z0-9._~+/=-]{16,512}$/
/**
* Parse WEBTERM_TOKEN. Unset/empty ⇒ undefined (auth DISABLED, LAN zero-config
* preserved). When present it must match WEBTERM_TOKEN_RE; invalid ⇒ throw
* (fail-fast, like parsePort). The token value is NEVER included in the error
* message (secret hygiene).
*/
function parseWebtermToken(raw: string | undefined): string | undefined {
if (raw === undefined || raw === '') return undefined
if (!WEBTERM_TOKEN_RE.test(raw)) {
throw new Error(
'Invalid config: WEBTERM_TOKEN must be 16512 characters from the ' +
'URL/cookie-safe set [A-Za-z0-9._~+/=-] (min length 16).',
)
}
return raw
}
function parsePort(raw: string | undefined): number {
if (raw === undefined) return DEFAULT_PORT
const n = Number(raw)
@@ -329,6 +354,10 @@ export function loadConfig(env: EnvLike): Config {
const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS'])
// w5-access-token — optional shared access token (SECRET — never logged here).
// Unset/empty ⇒ undefined ⇒ auth disabled (LAN zero-config unchanged).
const webtermToken = parseWebtermToken(env['WEBTERM_TOKEN'])
// v0.6 Project Manager — discovery config
const projectRoots = parseProjectRoots(env['PROJECT_ROOTS'], homeDir)
const projectScanDepth = parseNonNegativeInt(
@@ -474,6 +503,7 @@ export function loadConfig(env: EnvLike): Config {
previewBytes,
useTmux,
allowedOrigins,
webtermToken,
projectRoots,
projectScanDepth,
projectScanTtlMs,

146
src/http/auth.ts Normal file
View File

@@ -0,0 +1,146 @@
/**
* src/http/auth.ts — optional shared access-token gate (w5-access-token).
*
* Pure, dependency-light helpers (mirrors the shape/discipline of
* src/http/origin.ts). This layer is **ADDITIVE**: it sits *in front of* the
* existing Origin/CSWSH defence (src/http/origin.ts) and CSRF guard
* (`requireAllowedOrigin` in src/server.ts) — it never replaces or weakens them.
*
* When `WEBTERM_TOKEN` is unset/empty the whole gate is DISABLED — every helper
* that consults `cfg.webtermToken` short-circuits and the server wiring calls
* `next()` unconditionally, so behaviour is byte-identical to today (LAN
* zero-config preserved). The gate activates only when a token is configured.
*
* ── HONEST SECURITY BOUNDARY (read before trusting this) ────────────────────
* This is a **bar-raiser, NOT a TLS/Tailscale substitute.** On a bare-LAN
* `ws://`/`http://` deployment the terminal stream — and therefore this cookie
* and token — travel in CLEARTEXT. Anyone sniffing the LAN sees the token and
* can REPLAY it (there is no per-request nonce, no channel binding). The token
* only meaningfully hardens the relay/tunnel path, where the edge terminates
* TLS and the browser speaks `wss://`/`https://`. It is a single shared secret:
* no per-user identity, no revocation except changing the env var + restarting,
* no lockout beyond rate-limiting. Do NOT read it as "safe on the public
* internet" — the TECH_DOC §7 "never port-forward this raw" guidance stands.
*/
import { createHash, timingSafeEqual } from 'node:crypto'
import type { Config } from '../types.js'
/** The auth cookie name. HttpOnly (JS can't read it → XSS can't exfiltrate). */
export const AUTH_COOKIE_NAME = 'webterm_auth'
/** Cookie lifetime (seconds). Shipped as a constant (YAGNI — no env dial in v1);
* 30 days balances "don't re-auth every session" against a bounded replay window. */
const COOKIE_TTL_SEC = 30 * 24 * 60 * 60 // 2592000 (30 days)
/** Minimal structural view of an incoming request — keeps this module free of
* Express/DOM types (a real `http.IncomingMessage`/Express `Request` is
* structurally assignable). Used only by `isHttpsRequest`. */
export interface RequestLike {
readonly headers: Readonly<Record<string, string | string[] | undefined>>
// `remoteAddress` is here only so a real `net.Socket` is structurally
// assignable (TS weak-type check needs a shared property); `encrypted` (a
// `tls.TLSSocket` field) is the one this module actually reads.
readonly socket?: { readonly encrypted?: boolean; readonly remoteAddress?: string } | undefined
}
/** True iff auth is enabled: a non-empty token is configured. Unset/empty ⇒
* DISABLED (the single central switch — no scattered `if` checks elsewhere). */
export function isAuthEnabled(cfg: Pick<Config, 'webtermToken'>): boolean {
return typeof cfg.webtermToken === 'string' && cfg.webtermToken.length > 0
}
/**
* Parse a raw `Cookie:` header into a name→value map.
* Malformed pairs (no `=`, empty name) are ignored; last write wins on dup names.
* Values are returned verbatim (NOT URL-decoded) — our token uses a cookie-safe
* charset (validated at config load) so there is nothing to decode.
*/
export function parseCookieHeader(header: string | undefined): Record<string, string> {
const out: Record<string, string> = {}
if (header === undefined || header === '') return out
for (const part of header.split(';')) {
const eq = part.indexOf('=')
if (eq <= 0) continue // no '=' or empty name → ignore
const name = part.slice(0, eq).trim()
if (name === '') continue
out[name] = part.slice(eq + 1).trim()
}
return out
}
/**
* Constant-time string comparison (SECURITY-CRITICAL — never use `===`).
*
* Both inputs are hashed with SHA-256 to a FIXED 32 bytes, then compared with
* `crypto.timingSafeEqual`. Hashing-to-fixed-length is the "fixed-length guard":
* it removes the length side-channel (unequal-length secrets no longer leak
* their length via timing) AND sidesteps `timingSafeEqual`'s throw-on-length-
* mismatch. A present-vs-absent (empty/undefined) candidate short-circuits to
* `false` before the comparator — a missing value is not a secret-compare oracle,
* not a timing signal to protect.
*/
export function constantTimeEqual(a: string | undefined, b: string | undefined): boolean {
// Boundary guard (input validation): never trust the candidate. An empty or
// absent value can never be a match and must not reach the comparator.
if (typeof a !== 'string' || typeof b !== 'string') return false
if (a.length === 0 || b.length === 0) return false
const ha = createHash('sha256').update(a, 'utf8').digest()
const hb = createHash('sha256').update(b, 'utf8').digest()
return timingSafeEqual(ha, hb) // both are exactly 32 bytes → never throws
}
/**
* True iff the request's cookie carries a valid auth token.
* Disabled config (no token) ⇒ `false` — callers MUST short-circuit on
* `isAuthEnabled` first; a disabled gate never treats anyone as "authed".
*/
export function cookieIsAuthed(
cfg: Pick<Config, 'webtermToken'>,
cookieHeader: string | undefined,
): boolean {
const token = cfg.webtermToken
if (typeof token !== 'string' || token.length === 0) return false
const presented = parseCookieHeader(cookieHeader)[AUTH_COOKIE_NAME]
return constantTimeEqual(presented, token)
}
/**
* Build the `Set-Cookie` header value for a successful auth.
*
* Flags: `HttpOnly` (XSS can't read it), `SameSite=Strict` (cross-site pages
* can't ride the cookie — complements the Origin/CSWSH defence), `Path=/`,
* `Max-Age=<ttl>`, and `Secure` **only when `opts.secure`**. The dynamic
* `Secure` is required: a `Secure` cookie is never sent over `ws://`/`http://`,
* so forcing it would silently break LAN-over-HTTP auth; over the relay
* (`x-forwarded-proto: https`) it must be present.
*/
export function buildSetCookie(
cfg: Pick<Config, 'webtermToken'>,
opts: { secure: boolean },
): string {
const parts = [
`${AUTH_COOKIE_NAME}=${cfg.webtermToken ?? ''}`,
'Path=/',
`Max-Age=${COOKIE_TTL_SEC}`,
'HttpOnly',
'SameSite=Strict',
]
if (opts.secure) parts.push('Secure')
return parts.join('; ')
}
/**
* True iff the request arrived over HTTPS/WSS — either directly
* (`socket.encrypted`) or behind a TLS-terminating edge that set
* `x-forwarded-proto: https` (the relay/tunnel path). Drives the dynamic
* `Secure` cookie flag above.
*/
export function isHttpsRequest(req: RequestLike): boolean {
const xfp = req.headers['x-forwarded-proto']
const proto = Array.isArray(xfp) ? xfp[0] : xfp
if (typeof proto === 'string' && proto.split(',')[0]?.trim().toLowerCase() === 'https') {
return true
}
return req.socket?.encrypted === true
}

View File

@@ -24,9 +24,10 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { randomUUID } from 'node:crypto'
import { stat } from 'node:fs/promises'
import { readFileSync } from 'node:fs'
import express from 'express'
import type { Response } from 'express'
import type { NextFunction, Request, Response } from 'express'
import { WebSocketServer } from 'ws'
import type { WebSocket as WsWebSocket } from 'ws'
import type { IncomingMessage } from 'node:http'
@@ -34,6 +35,13 @@ import type { IncomingMessage } from 'node:http'
import { loadConfig } from './config.js'
import { parseClientMessage, serialize, SESSION_ID_RE } from './protocol.js'
import { isOriginAllowed } from './http/origin.js'
import {
buildSetCookie,
constantTimeEqual,
cookieIsAuthed,
isAuthEnabled,
isHttpsRequest,
} from './http/auth.js'
import { parseHookEvent } from './http/hook.js'
import { deriveApprovalPreview } from './http/approval-preview.js'
import { listSessions } from './http/history.js'
@@ -84,6 +92,16 @@ const SUBSCRIBE_RATE_MAX = 5 // POST/DELETE /push/subscribe ≤ 5/min/IP
const QUEUE_RATE_MAX = 20 // W2: POST/DELETE /live-sessions/:id/queue ≤ 20/min/IP
const GIT_WRITE_RATE_MAX = 30 // W4: POST /projects/git/{stage,commit} ≤ 30/min/IP
const GIT_PUSH_RATE_MAX = 6 // W4: POST /projects/git/push ≤ 6/min/IP (network-bound)
const AUTH_RATE_MAX = 10 // w5-access-token: POST /auth + GET /?token= ≤ 10/min/IP (brute-force guard)
/** Loopback-only Claude Code hook ingest paths the auth gate lets through from a
* loopback peer with no cookie (the token is about REMOTE access; hooks run on
* the host). Each handler independently re-checks isLoopback (defense in depth). */
const LOOPBACK_INGEST_PATHS: ReadonlySet<string> = new Set([
'/hook',
'/hook/permission',
'/hook/status',
])
/** The four permission modes the WS approve relay accepts (B4); else undefined. */
const PERMISSION_MODES: ReadonlySet<string> = new Set<PermissionMode>([
@@ -230,6 +248,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
const queueLimiter = createRateLimiter(QUEUE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W2
const gitWriteLimiter = createRateLimiter(GIT_WRITE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 stage/commit
const gitPushLimiter = createRateLimiter(GIT_PUSH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 push
const authLimiter = createRateLimiter(AUTH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // w5-access-token
// W2: per-session settle timers for the idle-drain. A Stop/SessionEnd hook
// schedules a debounced drain of one queue entry after cfg.queueSettleMs, so
@@ -313,9 +332,134 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
next()
})
const publicDir = path.join(__dirname, '..', 'public')
// ── w5-access-token: optional shared-token gate (ADDITIVE — in front of the
// Origin/CSRF model, never replacing it). Unset WEBTERM_TOKEN ⇒ DISABLED ⇒
// every request falls straight through `next()` (LAN zero-config unchanged).
//
// HONEST BOUNDARY: a bar-raiser, NOT a TLS/Tailscale substitute. On bare ws://
// the cookie/token travel in cleartext and are replayable by a LAN sniffer;
// this only meaningfully hardens the relay/tunnel (TLS-terminated) path. See
// src/http/auth.ts and TECH_DOC §7 ("never port-forward this raw").
// Startup-cached login page (served by GET /login BEFORE the gate, so it is
// always reachable while unauthed). Fallback keeps the server alive if the
// asset is missing rather than crashing at boot.
const loginHtml = ((): string => {
try {
return readFileSync(path.join(publicDir, 'login.html'), 'utf8')
} catch {
return '<!doctype html><meta charset="utf-8"><title>Sign in</title><form method="POST" action="/auth"><input type="password" name="token" autofocus><button>Unlock</button></form>'
}
})()
/** True for a top-level browser navigation (wants HTML) vs an XHR/fetch (wants JSON). */
function acceptsHtml(req: Request): boolean {
const accept = req.headers['accept']
return typeof accept === 'string' && accept.includes('text/html')
}
/** Redirect target for the `?token=` bootstrap: same path with `token` stripped
* so no secret is left in browser history / Referer. */
function pathWithoutToken(req: Request): string {
const u = new URL(req.originalUrl, 'http://localhost')
u.searchParams.delete('token')
const qs = u.searchParams.toString()
return u.pathname + (qs ? `?${qs}` : '')
}
// GET /login — always reachable. On ?e=1 reveal the error banner (no JS: the
// CSP is `script-src 'self'`, so the toggle is a server-side class swap).
app.get('/login', (req, res) => {
const html =
req.query['e'] === '1'
? loginHtml.replace('ERRSTATE', 'show-error')
: loginHtml.replace('ERRSTATE', '')
res.type('html').send(html)
})
// POST /auth — always reachable, rate-limited. Accepts urlencoded (native form)
// AND json (XHR). Valid ⇒ Set-Cookie + 302→/ (form) or 204 (XHR). Invalid ⇒
// 302→/login?e=1 (form) or 401 (XHR). Over the limit ⇒ 429.
app.post(
'/auth',
express.urlencoded({ extended: false, limit: '1kb' }),
express.json({ limit: '1kb' }),
(req, res) => {
const ip = req.socket.remoteAddress ?? 'unknown'
if (!authLimiter(ip, Date.now())) {
res.status(429).end()
return
}
// Disabled ⇒ nothing to authenticate; acknowledge without setting a cookie.
if (!isAuthEnabled(cfg)) {
res.status(204).end()
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const candidate = typeof body['token'] === 'string' ? body['token'] : ''
const isForm = acceptsHtml(req)
if (constantTimeEqual(candidate, cfg.webtermToken)) {
res.setHeader('Set-Cookie', buildSetCookie(cfg, { secure: isHttpsRequest(req) }))
if (isForm) res.redirect(302, '/')
else res.status(204).end()
return
}
if (isForm) res.redirect(302, '/login?e=1')
else res.status(401).json({ error: 'invalid token' })
},
)
// The global auth gate. Runs after the security-headers middleware and BEFORE
// express.static + every API route below, so ONE central policy point covers
// the whole app (the origin.ts idiom). Allow-list, in order:
function authGate(req: Request, res: Response, next: NextFunction): void {
// 1. Disabled → everything open (zero-config LAN unchanged).
if (!isAuthEnabled(cfg)) {
next()
return
}
// 2. Loopback-only Claude Code hook ingest (POST /hook, /hook/permission,
// /hook/status) has no cookie and must keep working — the token is about
// REMOTE access, not the host's own hook side-channel. DEVIATION from the
// plan's blanket `isLoopback → next()`: scoped to these three ingest paths
// so the gate still protects every OTHER route even from a loopback peer
// (a token-enabled deploy then also gates the local browser — strictly
// more secure, and testable from a 127.0.0.1 harness).
if (LOOPBACK_INGEST_PATHS.has(req.path) && isLoopback(req.socket.remoteAddress ?? '')) {
next()
return
}
// 3. GET with ?token= bootstrap link → validate (rate-limited), set cookie +
// redirect to the same path with the token stripped, else → /login.
if (req.method === 'GET' && typeof req.query['token'] === 'string' && req.query['token'] !== '') {
const ip = req.socket.remoteAddress ?? 'unknown'
if (!authLimiter(ip, Date.now())) {
res.status(429).end()
return
}
if (constantTimeEqual(req.query['token'], cfg.webtermToken)) {
res.setHeader('Set-Cookie', buildSetCookie(cfg, { secure: isHttpsRequest(req) }))
res.redirect(302, pathWithoutToken(req))
} else {
res.redirect(302, '/login')
}
return
}
// 4. Valid cookie → allow.
if (cookieIsAuthed(cfg, req.headers['cookie'])) {
next()
return
}
// 5. Unauthed: HTML navigation → login page; XHR/other → 401 JSON.
if (acceptsHtml(req)) res.redirect(302, '/login')
else res.status(401).json({ error: 'authentication required' })
}
app.use(authGate)
// Serve the entire public/ directory (including public/build/ esbuild output).
// Note: `npm run build:web` must be run before `npm start` to populate public/build/.
const publicDir = path.join(__dirname, '..', 'public')
app.use(express.static(publicDir))
// ── Claude Code history (O2) — list past sessions for the resume browser ──
@@ -1156,6 +1300,16 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
return
}
// 2b. Access-token gate (w5-access-token) — ADDITIVE, runs AFTER the Origin
// check (never weakens it) and BEFORE the handshake. The browser
// auto-sends the HttpOnly cookie on the same-origin WS upgrade, so no
// frontend change is needed. Disabled (no token) → this is a no-op.
if (isAuthEnabled(cfg) && !cookieIsAuthed(cfg, req.headers['cookie'])) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
socket.destroy()
return
}
// 3. Hand off to wss — it completes the handshake and emits 'connection'.
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req)

View File

@@ -34,6 +34,11 @@ export interface Config {
readonly previewBytes: number; // manage-page preview: bytes of scrollback tail rendered
readonly useTmux: boolean; // H1: spawn the shell inside tmux so it survives a server restart
readonly allowedOrigins: readonly string[]; // derived from NIC IPs, NOT bindHost (M1)
// w5-access-token — optional shared access token gate (ADDITIVE, in front of the
// Origin/CSRF model; never replaces it). undefined/empty ⇒ auth DISABLED, so LAN
// zero-config is preserved. When set: validated at load (16512 URL/cookie-safe
// chars). SECRET — never log; never return over /config/ui. See src/http/auth.ts.
readonly webtermToken: string | undefined; // WEBTERM_TOKEN
// v0.6 Project Manager — project discovery (impl: src/config.ts T-PM3)
readonly projectRoots: readonly string[]; // PROJECT_ROOTS, default [homeDir]
readonly projectScanDepth: number; // PROJECT_SCAN_DEPTH, default 4

139
test/auth.test.ts Normal file
View File

@@ -0,0 +1,139 @@
/**
* test/auth.test.ts — unit tests for src/http/auth.ts (w5-access-token).
*
* Mirrors the discipline of test/origin.test.ts. Security-critical: the
* constant-time comparison, the cookie flags, and the disabled short-circuit.
*/
import { describe, it, expect } from 'vitest'
import {
AUTH_COOKIE_NAME,
buildSetCookie,
constantTimeEqual,
cookieIsAuthed,
isAuthEnabled,
isHttpsRequest,
parseCookieHeader,
} from '../src/http/auth.js'
const TOKEN = 'super-secret-token-1234' // ≥16, safe charset
// ── constantTimeEqual ─────────────────────────────────────────────────────────
describe('constantTimeEqual', () => {
it('returns true for equal strings', () => {
expect(constantTimeEqual(TOKEN, TOKEN)).toBe(true)
})
it('returns false for unequal same-length strings', () => {
expect(constantTimeEqual('aaaaaaaaaaaaaaaa', 'aaaaaaaaaaaaaaab')).toBe(false)
})
it('returns false for different-length strings without throwing (fixed-length guard)', () => {
// The SHA-256-to-fixed-32-bytes guard means unequal lengths never throw.
expect(() => constantTimeEqual('short', 'a-much-longer-candidate-value')).not.toThrow()
expect(constantTimeEqual('short', 'a-much-longer-candidate-value')).toBe(false)
})
it('returns false for an empty candidate (present-vs-absent short-circuit)', () => {
expect(constantTimeEqual('', TOKEN)).toBe(false)
expect(constantTimeEqual(TOKEN, '')).toBe(false)
})
it('returns false for an undefined candidate', () => {
expect(constantTimeEqual(undefined, TOKEN)).toBe(false)
expect(constantTimeEqual(TOKEN, undefined)).toBe(false)
})
})
// ── parseCookieHeader ─────────────────────────────────────────────────────────
describe('parseCookieHeader', () => {
it('parses a multi-pair cookie header into a map', () => {
const map = parseCookieHeader('a=1; webterm_auth=xyz; b=2')
expect(map).toEqual({ a: '1', webterm_auth: 'xyz', b: '2' })
})
it('returns {} for a missing or empty header', () => {
expect(parseCookieHeader(undefined)).toEqual({})
expect(parseCookieHeader('')).toEqual({})
})
it('ignores malformed pairs (no = / empty name)', () => {
const map = parseCookieHeader('novalue; =orphan; webterm_auth=ok')
expect(map).toEqual({ webterm_auth: 'ok' })
})
})
// ── isAuthEnabled ─────────────────────────────────────────────────────────────
describe('isAuthEnabled', () => {
it('is false when the token is undefined', () => {
expect(isAuthEnabled({ webtermToken: undefined })).toBe(false)
})
it('is false when the token is an empty string', () => {
expect(isAuthEnabled({ webtermToken: '' })).toBe(false)
})
it('is true when a token is set', () => {
expect(isAuthEnabled({ webtermToken: TOKEN })).toBe(true)
})
})
// ── cookieIsAuthed ────────────────────────────────────────────────────────────
describe('cookieIsAuthed', () => {
it('is true for a correct auth cookie', () => {
expect(cookieIsAuthed({ webtermToken: TOKEN }, `${AUTH_COOKIE_NAME}=${TOKEN}`)).toBe(true)
})
it('is false for a wrong cookie value', () => {
expect(cookieIsAuthed({ webtermToken: TOKEN }, `${AUTH_COOKIE_NAME}=nope-nope-nope-nope`)).toBe(false)
})
it('is false when the auth cookie is absent', () => {
expect(cookieIsAuthed({ webtermToken: TOKEN }, 'other=1')).toBe(false)
expect(cookieIsAuthed({ webtermToken: TOKEN }, undefined)).toBe(false)
})
it('is false when auth is disabled (no token) regardless of cookie', () => {
expect(cookieIsAuthed({ webtermToken: undefined }, `${AUTH_COOKIE_NAME}=anything`)).toBe(false)
})
})
// ── buildSetCookie ────────────────────────────────────────────────────────────
describe('buildSetCookie', () => {
it('includes the token value and the required flags', () => {
const c = buildSetCookie({ webtermToken: TOKEN }, { secure: false })
expect(c).toContain(`${AUTH_COOKIE_NAME}=${TOKEN}`)
expect(c).toContain('Path=/')
expect(c).toContain('Max-Age=')
expect(c).toContain('HttpOnly')
expect(c).toContain('SameSite=Strict')
})
it('omits Secure when opts.secure is false (LAN over http)', () => {
expect(buildSetCookie({ webtermToken: TOKEN }, { secure: false })).not.toContain('Secure')
})
it('includes Secure when opts.secure is true (relay over https)', () => {
expect(buildSetCookie({ webtermToken: TOKEN }, { secure: true })).toContain('; Secure')
})
})
// ── isHttpsRequest ────────────────────────────────────────────────────────────
describe('isHttpsRequest', () => {
it('is true when x-forwarded-proto is https (TLS-terminating edge)', () => {
expect(isHttpsRequest({ headers: { 'x-forwarded-proto': 'https' } })).toBe(true)
})
it('reads the first value of a comma-joined x-forwarded-proto', () => {
expect(isHttpsRequest({ headers: { 'x-forwarded-proto': 'https, http' } })).toBe(true)
})
it('is true when the socket is encrypted (direct TLS)', () => {
expect(isHttpsRequest({ headers: {}, socket: { encrypted: true } })).toBe(true)
})
it('is false for a plain http request', () => {
expect(isHttpsRequest({ headers: {}, socket: { encrypted: false } })).toBe(false)
expect(isHttpsRequest({ headers: { 'x-forwarded-proto': 'http' } })).toBe(false)
})
})

View File

@@ -840,3 +840,49 @@ describe('loadConfig — W2 inject queue', () => {
expect(() => loadConfig({ QUEUE_SETTLE_MS: 'soon' })).toThrow()
})
})
describe('loadConfig — w5-access-token (WEBTERM_TOKEN)', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('is undefined when WEBTERM_TOKEN is unset (auth disabled — LAN zero-config)', () => {
expect(loadConfig({}).webtermToken).toBeUndefined()
})
it('is undefined when WEBTERM_TOKEN is the empty string (treated as unset)', () => {
expect(loadConfig({ WEBTERM_TOKEN: '' }).webtermToken).toBeUndefined()
})
it('stores a valid token verbatim (≥16 chars, safe charset)', () => {
const t = 'super-secret-token-1234'
expect(loadConfig({ WEBTERM_TOKEN: t }).webtermToken).toBe(t)
})
it('throws for a too-short token (< 16 chars)', () => {
expect(() => loadConfig({ WEBTERM_TOKEN: 'abc' })).toThrow(/WEBTERM_TOKEN/)
})
it('throws for a token with an unsafe char (semicolon → Set-Cookie injection)', () => {
expect(() => loadConfig({ WEBTERM_TOKEN: 'has;semicolon;inside0' })).toThrow(/WEBTERM_TOKEN/)
})
it('throws for a token with a space', () => {
expect(() => loadConfig({ WEBTERM_TOKEN: 'has a space in it here' })).toThrow(/WEBTERM_TOKEN/)
})
it('throws for a token with a control char', () => {
expect(() => loadConfig({ WEBTERM_TOKEN: 'ctrlchar-injection00' })).toThrow(/WEBTERM_TOKEN/)
})
it('never leaks the token value in the validation error message (secret hygiene)', () => {
const badToken = 'secretbutbad;value00'
expect(() => loadConfig({ WEBTERM_TOKEN: badToken })).toThrow()
try {
loadConfig({ WEBTERM_TOKEN: badToken })
} catch (e) {
expect(String((e as Error).message)).not.toContain(badToken)
}
})
})

View File

@@ -0,0 +1,337 @@
/**
* test/integration/auth.test.ts — real-server integration for the optional
* access-token gate (w5-access-token). Pattern mirrors integration/server.test.ts.
*
* Two axes:
* - DISABLED (no WEBTERM_TOKEN): everything works byte-identically to today —
* WS connects on Origin alone, state-changing routes gated only by Origin.
* - ENABLED: the gate + WS cookie check activate ALONGSIDE (never replacing)
* the Origin/CSRF model.
*
* All traffic here is loopback (127.0.0.1). The gate's loopback bypass is scoped
* to the /hook* ingest paths only, so non-hook routes are genuinely gated even
* from 127.0.0.1 — which is what makes the "unauthed → 401/302" cases testable.
*
* Each test gets its own port + server (beforeEach) so the per-IP auth rate
* limiter never leaks across cases.
*/
import net from 'node:net'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import WebSocket from 'ws'
import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
import type { Config } from '../../src/types.js'
const TOKEN = 'integration-secret-token-01' // ≥16, safe charset
const COOKIE = `webterm_auth=${TOKEN}`
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer()
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
if (!addr || typeof addr === 'string') {
srv.close()
reject(new Error('unexpected address type'))
return
}
const port = addr.port
srv.close(() => resolve(port))
})
srv.on('error', reject)
})
}
/** Build a config, optionally with WEBTERM_TOKEN set (auth enabled). */
function makeCfg(port: number, withToken: boolean): Config {
const env: Record<string, string> = {
PORT: String(port),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
SCROLLBACK_BYTES: String(64 * 1024),
IDLE_TTL: '86400',
USE_TMUX: '0',
}
if (withToken) env['WEBTERM_TOKEN'] = TOKEN
return loadConfig(env)
}
/** Resolve iff the WS opens; reject if it errors/closes before open. */
function waitForOpen(ws: WebSocket, timeoutMs = 3_000): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('WS open timeout')), timeoutMs)
ws.once('open', () => {
clearTimeout(timer)
resolve()
})
ws.once('error', (err) => {
clearTimeout(timer)
reject(err)
})
ws.once('close', () => {
clearTimeout(timer)
reject(new Error('WS closed before open'))
})
})
}
// ── DISABLED: zero behaviour change (the regression proof) ──────────────────────
describe('access-token gate — DISABLED (WEBTERM_TOKEN unset)', () => {
let port: number
let handle: { close(): Promise<void> }
beforeEach(async () => {
port = await getFreePort()
handle = startServer(makeCfg(port, false))
await new Promise<void>((r) => setTimeout(r, 80))
})
afterEach(async () => {
await handle.close()
await new Promise<void>((r) => setTimeout(r, 50))
})
it('WS handshake succeeds with a valid Origin and NO cookie (unchanged)', async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, {
headers: { Origin: `http://127.0.0.1:${port}` },
})
await expect(waitForOpen(ws)).resolves.toBeUndefined()
ws.terminate()
})
it('GET /live-sessions is open (no cookie required)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`)
expect(res.status).toBe(200)
expect(Array.isArray(await res.json())).toBe(true)
})
it('GET / serves the app (200, no login redirect)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/`, {
headers: { Accept: 'text/html' },
redirect: 'manual',
})
expect(res.status).toBe(200)
})
it('DELETE /live-sessions works with a valid Origin and no cookie (Origin-only, as today)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
method: 'DELETE',
headers: { Origin: `http://127.0.0.1:${port}` },
})
expect(res.status).toBe(200)
})
it('DELETE /live-sessions still 403s a foreign Origin (CSRF layer intact)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
method: 'DELETE',
headers: { Origin: 'http://evil.example' },
})
expect(res.status).toBe(403)
})
})
// ── ENABLED: the gate + WS cookie check activate ────────────────────────────────
describe('access-token gate — ENABLED (WEBTERM_TOKEN set)', () => {
let port: number
let handle: { close(): Promise<void> }
beforeEach(async () => {
port = await getFreePort()
handle = startServer(makeCfg(port, true))
await new Promise<void>((r) => setTimeout(r, 80))
})
afterEach(async () => {
await handle.close()
await new Promise<void>((r) => setTimeout(r, 50))
})
// ── WS handshake ──────────────────────────────────────────────────────────
it('WS: valid Origin + NO cookie → handshake rejected (401)', async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, {
headers: { Origin: `http://127.0.0.1:${port}` },
})
await expect(waitForOpen(ws)).rejects.toThrow()
ws.terminate()
})
it('WS: valid Origin + WRONG cookie → handshake rejected (401)', async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, {
headers: { Origin: `http://127.0.0.1:${port}`, Cookie: 'webterm_auth=wrong-value-000000' },
})
await expect(waitForOpen(ws)).rejects.toThrow()
ws.terminate()
})
it('WS: valid Origin + VALID cookie → handshake accepted (opens)', async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, {
headers: { Origin: `http://127.0.0.1:${port}`, Cookie: COOKIE },
})
await expect(waitForOpen(ws)).resolves.toBeUndefined()
ws.terminate()
})
it('WS: the Origin check still runs FIRST — bad Origin + valid cookie → 401 (additive, not replacing)', async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, {
headers: { Origin: 'http://evil.example', Cookie: COOKIE },
})
await expect(waitForOpen(ws)).rejects.toThrow()
ws.terminate()
})
// ── POST /auth ────────────────────────────────────────────────────────────
it('POST /auth with the WRONG token → 401 JSON (XHR)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/auth`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: 'nope-nope-nope-nope-00' }),
})
expect(res.status).toBe(401)
})
it('POST /auth with the CORRECT token (form) → 302 + Set-Cookie with HttpOnly/SameSite/Path/Max-Age, NO Secure over http', async () => {
const res = await fetch(`http://127.0.0.1:${port}/auth`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', Accept: 'text/html' },
body: `token=${encodeURIComponent(TOKEN)}`,
redirect: 'manual',
})
expect(res.status).toBe(302)
const setCookie = res.headers.get('set-cookie') ?? ''
expect(setCookie).toContain(`webterm_auth=${TOKEN}`)
expect(setCookie).toContain('HttpOnly')
expect(setCookie).toContain('SameSite=Strict')
expect(setCookie).toContain('Path=/')
expect(setCookie).toContain('Max-Age=')
expect(setCookie).not.toContain('Secure')
})
it('POST /auth with the CORRECT token (XHR) → 204 + Set-Cookie', async () => {
const res = await fetch(`http://127.0.0.1:${port}/auth`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: TOKEN }),
})
expect(res.status).toBe(204)
expect(res.headers.get('set-cookie') ?? '').toContain('webterm_auth=')
})
it('POST /auth over x-forwarded-proto: https → Set-Cookie INCLUDES Secure', async () => {
const res = await fetch(`http://127.0.0.1:${port}/auth`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-proto': 'https' },
body: JSON.stringify({ token: TOKEN }),
})
expect(res.status).toBe(204)
expect(res.headers.get('set-cookie') ?? '').toContain('Secure')
})
it('POST /auth is rate-limited: >10 bad attempts/min → 429', async () => {
let saw429 = false
for (let i = 0; i < 12; i++) {
const res = await fetch(`http://127.0.0.1:${port}/auth`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: 'bad-attempt-value-000' }),
})
if (res.status === 429) saw429 = true
}
expect(saw429).toBe(true)
})
// ── GET /?token= bootstrap ──────────────────────────────────────────────────
it('GET /?token=<valid> → 302 to a path with NO token + Set-Cookie', async () => {
const res = await fetch(`http://127.0.0.1:${port}/?token=${encodeURIComponent(TOKEN)}`, {
redirect: 'manual',
})
expect(res.status).toBe(302)
const loc = res.headers.get('location') ?? ''
expect(loc).not.toContain('token')
expect(res.headers.get('set-cookie') ?? '').toContain('webterm_auth=')
})
it('GET /?token=<invalid> → 302 to /login, NO cookie', async () => {
const res = await fetch(`http://127.0.0.1:${port}/?token=wrong-token-value-00`, {
redirect: 'manual',
})
expect(res.status).toBe(302)
expect(res.headers.get('location')).toBe('/login')
expect(res.headers.get('set-cookie')).toBeNull()
})
// ── /login reachable + unauthed routing ─────────────────────────────────────
it('GET /login → 200 HTML, reachable while unauthed', async () => {
const res = await fetch(`http://127.0.0.1:${port}/login`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type') ?? '').toContain('text/html')
expect(await res.text()).toContain('<form')
})
it('GET /login?e=1 → the error banner is revealed (show-error)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/login?e=1`)
expect(await res.text()).toContain('show-error')
})
it('unauthed HTML navigation (GET /, no cookie) → 302 /login', async () => {
const res = await fetch(`http://127.0.0.1:${port}/`, {
headers: { Accept: 'text/html' },
redirect: 'manual',
})
expect(res.status).toBe(302)
expect(res.headers.get('location')).toBe('/login')
})
it('unauthed XHR (GET /live-sessions, no cookie) → 401 JSON', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`)
expect(res.status).toBe(401)
expect(await res.json()).toEqual({ error: 'authentication required' })
})
it('authed XHR (GET /live-sessions with valid cookie) → 200', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
headers: { Cookie: COOKIE },
})
expect(res.status).toBe(200)
expect(Array.isArray(await res.json())).toBe(true)
})
// ── Gate + CSRF stacking on a state-changing route ──────────────────────────
it('DELETE /live-sessions, valid Origin, NO cookie → 401 (gate blocks before CSRF)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
method: 'DELETE',
headers: { Origin: `http://127.0.0.1:${port}` },
})
expect(res.status).toBe(401)
})
it('DELETE /live-sessions, valid Origin + valid cookie → 200 (passes gate, then CSRF)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
method: 'DELETE',
headers: { Origin: `http://127.0.0.1:${port}`, Cookie: COOKIE },
})
expect(res.status).toBe(200)
})
it('DELETE /live-sessions, valid cookie but FOREIGN Origin → 403 (CSRF layer still independent)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
method: 'DELETE',
headers: { Origin: 'http://evil.example', Cookie: COOKIE },
})
expect(res.status).toBe(403)
})
// ── Loopback hook ingest bypass (guard against regressing the side-channel) ──
it('POST /hook from loopback with NO cookie still returns 204 (hooks unaffected)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/hook`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-webterm-session': '00000000-0000-4000-8000-000000000000',
},
body: JSON.stringify({ hook_event_name: 'Stop' }),
})
expect(res.status).toBe(204)
})
})