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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions specs/api/agent-inbox-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ added when a real need appears, not preemptively.
wrong token is `401 unauthorized` with a generic message — the response never reveals
which of those it was. (The open-tracking pixel, §4g, is the one deliberate exception
to Bearer auth — it is fetched by customer mail clients and carries its own rules.)
**This is still the API's only auth model (HT-51, §5).** The Agent Inbox web app now
requires an operator to sign in before it will render any page, but that is a web-layer
door in front of this same Bearer token, not a second API auth mechanism — see §5 for
the full justification.
Comment on lines +130 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document the complete public-path exception set.

The route gate does not protect literally every route/page: web/src/middleware.ts also permits /favicon.ico, /robots.txt, /sitemap.xml, and /manifest.webmanifest without a session. Use “protected routes/pages” or list those exceptions explicitly.

  • specs/api/agent-inbox-v1.md#L130-L133: replace “before it will render any page” with wording that excludes the public paths.
  • specs/api/agent-inbox-v1.md#L388-L393: change “every route”/“render anything” to describe protected application routes and the public-path exceptions.
  • specs/api/agent-inbox-v1.md#L427-L432: update the changelog wording so it does not claim a cookie is required before rendering any page.
  • web/README.md#L55-L59: replace “every route requires” with “every protected route requires” or enumerate all PUBLIC_PATHS.
📍 Affects 2 files
  • specs/api/agent-inbox-v1.md#L130-L133 (this comment)
  • specs/api/agent-inbox-v1.md#L388-L393
  • specs/api/agent-inbox-v1.md#L427-L432
  • web/README.md#L55-L59
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specs/api/agent-inbox-v1.md` around lines 130 - 133, Update the
authentication documentation to distinguish protected application routes from
the public-path exceptions permitted by web/src/middleware.ts:
specs/api/agent-inbox-v1.md lines 130-133, 388-393, and 427-432, plus
web/README.md lines 55-59. Replace claims that every page or route requires a
session/cookie with “protected routes/pages” wording or explicitly list
/favicon.ico, /robots.txt, /sitemap.xml, and /manifest.webmanifest.

- **Never cache:** every response carries `Cache-Control: no-store`. This is authenticated
support data; no edge or CDN copy, ever.
- **Error envelope:**
Expand Down Expand Up @@ -381,6 +385,29 @@ above.
rule to its unauthenticated surface: `200` + gif regardless of token validity.
- **The Bearer token is a service credential.** It grants the whole inbox. It is compared
in constant time and read only from server configuration, never logged.
- **UI session auth (HT-51) is a web-layer door in front of this same token, not a second
auth model.** Before HT-51, the Agent Inbox web app had no login at all — every request
it made carried the deployment's `HELPTHREAD_API_TOKEN` and nothing distinguished one
browser tab from another. HT-51 adds an operator password (`HELPTHREAD_UI_PASSWORD`)
and a signed session cookie (`web/src/lib/session.ts`, checked by `web/src/middleware.ts`
on every route) that the browser must hold before the UI will render anything. This
changes nothing about the API described in this document:
- The API still authenticates every request by `HELPTHREAD_API_TOKEN` alone (constant-time
Bearer comparison, above) and has no knowledge of UI sessions, passwords, or cookies —
Comment on lines +395 to +396

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Preserve the documented tracking-pixel exception.

The statement that the API authenticates “every request” with HELPTHREAD_API_TOKEN contradicts §4g, which explicitly permits the unauthenticated open-tracking pixel. Say “every authenticated API request” and retain the §4g exception here.

Suggested wording
-  - The API still authenticates every request by `HELPTHREAD_API_TOKEN` alone (constant-time
+  - The API still authenticates every authenticated request by `HELPTHREAD_API_TOKEN` alone
+    (constant-time
     Bearer comparison, above) and has no knowledge of UI sessions, passwords, or cookies —
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- The API still authenticates every request by `HELPTHREAD_API_TOKEN` alone (constant-time
Bearer comparison, above) and has no knowledge of UI sessions, passwords, or cookies —
- The API still authenticates every authenticated request by `HELPTHREAD_API_TOKEN` alone
(constant-time Bearer comparison, above) and has no knowledge of UI sessions, passwords, or cookies —
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specs/api/agent-inbox-v1.md` around lines 395 - 396, Update the
authentication statement near the Bearer comparison to say “every authenticated
API request” rather than “every request,” and retain an explicit reference to
the unauthenticated open-tracking pixel exception documented in §4g.

`web/src/lib/api.ts` still reads the token from server env and sends it exactly as
before. Anything holding the token can still call the API directly, session or no
session; that was already true (the token is a service credential, not tied to a
browser) and HT-51 doesn't change it.
- The session cookie carries no identity beyond "an operator signed in" (`{v, iat}`,
nothing else) — v1 is still single-Agent (§1, §6), so there is nothing for it to be an
identity FOR yet. It answers "is anyone allowed to look at this browser tab's inbox",
which is a strictly web-layer question the API was never positioned to answer (a
server-to-server Bearer token can't gate "is a human currently present").
- Multi-Agent identity (§6, "No multi-Agent identity, teams, or per-user authorization")
remains out of scope and unaffected. When it lands, it is expected to REPLACE this
single shared password with real per-Agent accounts, not extend it — HT-51 is
deliberately the smallest thing that closes the "anyone with the URL sees the inbox"
gap for a single operator, not a first draft of multi-user auth.

## 6. What v1 is NOT

Expand All @@ -397,6 +424,12 @@ above.

## 7. Changelog

- **v1.1 (2026-07-17, HT-51).** Documented the Agent Inbox web app's new operator login
(§3, §5) — a session cookie the UI now requires before rendering any page. No API
behavior changed: this is a web-layer addition in front of the unchanged
`HELPTHREAD_API_TOKEN` Bearer auth, recorded here only because §5's prior security notes
implied the UI itself had no auth story of its own. See §5's HT-51 bullet for the full
justification.
- **v1.1 (2026-07-17, HT-49 review fix).** `InboxApiDeps.selfEchoGuard` (optional, absent
by default): when present — and when the sender reports a provider message id for a
resolvable outbound mailbox — the send path best-effort pre-seeds a successful reply's
Expand Down
19 changes: 19 additions & 0 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ The app's dev defaults match the harness (`http://localhost:8787`,
API client (`src/lib/api.ts`) imports `server-only`, so the token can never
reach a client bundle; every API call runs in a server component or server
action.
- `HELPTHREAD_UI_PASSWORD` — the operator login password (HT-51), required to
be at least 12 characters in production. Compared constant-time
(`src/lib/auth-actions.ts`) against what's submitted on `/login`; there is
no per-Agent account, just this one shared password (v1 is single-Agent —
see `specs/api/agent-inbox-v1.md` §1).
- `HELPTHREAD_UI_SESSION_SECRET` — the HMAC secret signing the login session
cookie (`src/lib/session.ts`), required to be at least 32 characters in
production. Checked on every route by `src/middleware.ts`, which runs on
Next's Edge runtime — hence Web Crypto (`crypto.subtle`) rather than
`node:crypto` for the cookie's HMAC, unlike the password comparison above.

Both `HELPTHREAD_UI_*` vars have obviously-dev-only fallbacks in local
development (matching the `HELPTHREAD_API_TOKEN` dev-default pattern above)
and are REQUIRED — with no fallback — once `NODE_ENV=production`.
Comment on lines +26 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Qualify the production fallback claim for build time.

uiAuthConfig() intentionally skips required-env validation during NEXT_PHASE=phase-production-build, so these defaults can exist during next build; the fail-closed behavior applies at production runtime. The README currently says there is no fallback whenever NODE_ENV=production.

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

In `@web/README.md` around lines 26 - 39, Update the README description of
HELPTHREAD_UI_* fallback behavior to clarify that production-build time
(NEXT_PHASE=phase-production-build) may use development defaults because
uiAuthConfig() skips required-environment validation. State that the
no-fallback, fail-closed requirement applies during production runtime, and
preserve the existing local-development behavior.


## Where things live

Expand All @@ -38,6 +52,11 @@ The app's dev defaults match the harness (`http://localhost:8787`,
- `src/components/SanitizedHtml.tsx` — the ONE place inbound email HTML is
rendered: DOMPurify always (spec §5's stored-XSS contract), remote images
stripped.
- `src/middleware.ts` / `src/lib/session.ts` / `src/lib/auth-actions.ts` /
`src/lib/next-path.ts` — the operator login gate (HT-51): every route
requires a signed session cookie except `/login` itself. See
`specs/api/agent-inbox-v1.md` §5 for why this is a web-layer addition, not
a change to the API's own auth model.

## The fidelity mandate (TJ, 2026-07-12)

Expand Down
25 changes: 25 additions & 0 deletions web/src/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Metadata } from 'next'
import { LoginScreen } from '../../components/LoginScreen'
import { sanitizeNextPath } from '../../lib/next-path'

export const metadata: Metadata = {
title: 'Sign in — Helpthread',
}

/**
* The one public route `middleware.ts` lets through unauthenticated. Reads
* `?next=` (where the operator was headed before the middleware redirected
* them here) and sanitizes it immediately — see `lib/next-path.ts` — before
* handing it to the client `LoginScreen`, which passes it straight through to
* `loginAction` on submit. `loginAction` re-sanitizes the same value before
* redirecting (defense in depth costs one function call; the two call sites
* don't need to trust each other).
*/
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ next?: string }>
}) {
const { next } = await searchParams
return <LoginScreen next={sanitizeNextPath(next ?? null)} />
}
3 changes: 2 additions & 1 deletion web/src/components/AuthFailure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ export function AuthFailure() {
}}
>
Every request is signed with this deployment&rsquo;s service token, and the API just
rejected it. There&rsquo;s nothing to sign into — this is configuration, not a login.
rejected it. Your sign-in is fine — this is the deployment&rsquo;s service token, not your
login.
</p>
<code
style={{
Expand Down
183 changes: 183 additions & 0 deletions web/src/components/LoginScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
'use client'

/**
* The operator login screen (HT-51). The frozen Claude Design prototype has
* no login screen at all — the pre-HT-51 API posture was "no login, just a
* deployment-held Bearer token" (see the `agent-inbox-v1.md` §3/§5 amendment
* landing in the same commit as this file). This is therefore a NEW designed
* surface with no prototype to match pixel-for-pixel; it borrows `AuthFailure`'s
* fixed-full-screen, calm, no-blame register as the closest sibling, but it
* has NOT been through TJ's design sign-off. **Flagging prominently per
* CLAUDE.md's UI-fidelity mandate — treat these pixels as a placeholder until
* reviewed.**
*
* Two deliberate departures from "compose only from `ds/**`", both because
* the frozen design system genuinely lacks the piece needed, not because it
* was inconvenient to use. Tracked upstream as HT-52 (add a `type` prop to
* `TextInput`, a `type="submit"` option to `Button`) so these two workarounds
* have an expiry path instead of becoming a permanent fork:
*
* - The password field is a native `<input type="password">`, not
* `ds/core/TextInput` — that component hardcodes `type="text"` (see its
* `.jsx`) with no prop to ask for anything else. Styled inline with the
* same tokens `TextInput` uses, so it reads as the same input, just able to
* mask what's typed and offer `autoComplete="current-password"`.
* - The submit control IS `ds/core/Button`, but `Button` hardcodes
* `type="button"` — there's no `type="submit"` escape hatch — so clicking
* it calls `formRef.current?.requestSubmit()` to submit the surrounding
* `<form>` via JS instead of relying on native submit-button semantics.
* Pressing Enter in the password field still submits the form natively,
* no JS trick needed for that path.
*/

import { useRef, useState, useTransition } from 'react'
import { loginAction } from '../lib/auth-actions'
import { Button } from './ds/core/Button'

export function LoginScreen({ next }: { next: string }) {
const formRef = useRef<HTMLFormElement>(null)
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()

function submit(): void {
if (isPending) return
setError(null)
startTransition(async () => {
const result = await loginAction(password, next)
// A successful login redirects server-side and never returns here —
// reaching this line means the password didn't match.
if (!result.ok) {
setError(result.message ?? "That password didn't match.")
}
})
}

return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 90,
background: 'var(--ht-bg)',
color: 'var(--ht-ink)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
padding: 24,
}}
>
<div
style={{
fontFamily: "var(--ht-serif, 'Source Serif 4', serif)",
fontSize: 18,
fontWeight: 600,
letterSpacing: '0.01em',
}}
>
helpthread<span style={{ color: 'var(--ht-accent)' }}>.</span>
</div>

<h1
style={{
fontFamily: 'var(--ht-display)',
fontSize: 22,
fontWeight: 600,
margin: '18px 0 0',
}}
>
Sign in to your inbox
</h1>

<p
style={{
margin: '12px 0 0',
maxWidth: 340,
fontSize: 14.5,
lineHeight: 1.65,
color: 'var(--ht-ink-muted)',
}}
>
This deployment has one operator password — there's no separate account to create.
</p>
Comment on lines +94 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the required “Agent” terminology consistently.

The implementation repeatedly calls the human support actor an “operator,” including in visible login copy.

  • web/src/components/LoginScreen.tsx#L94-L104: change visible “operator password” copy to “Agent password.”
  • web/src/lib/session.ts#L2-L9: replace “operator” in the session-model documentation.
  • web/src/middleware.ts#L9-L23: replace “operator” in the route-gate documentation.
  • web/src/lib/actions.ts#L54-L64: describe this as an Agent session.
  • web/src/lib/next-path.ts#L1-L19: replace “operator” in redirect documentation.
  • web/src/lib/auth-actions.ts#L3-L10: replace “operator” in authentication documentation.
  • web/src/app/login/page.tsx#L9-L16: replace “operator” in page documentation.

As per coding guidelines, “Use Agents exclusively for human support staff and Assistants exclusively for AI actors.” <coding_guidelines>

📍 Affects 7 files
  • web/src/components/LoginScreen.tsx#L94-L104 (this comment)
  • web/src/lib/session.ts#L2-L9
  • web/src/middleware.ts#L9-L23
  • web/src/lib/actions.ts#L54-L64
  • web/src/lib/next-path.ts#L1-L19
  • web/src/lib/auth-actions.ts#L3-L10
  • web/src/app/login/page.tsx#L9-L16
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/LoginScreen.tsx` around lines 94 - 104, Replace
human-support “operator” terminology with “Agent” consistently: update the
visible password copy in web/src/components/LoginScreen.tsx lines 94-104, and
revise the related documentation in web/src/lib/session.ts lines 2-9,
web/src/middleware.ts lines 9-23, web/src/lib/actions.ts lines 54-64,
web/src/lib/next-path.ts lines 1-19, web/src/lib/auth-actions.ts lines 3-10, and
web/src/app/login/page.tsx lines 9-16. Keep “Assistants” reserved for AI actors.

Source: Coding guidelines


<form
ref={formRef}
onSubmit={(event) => {
event.preventDefault()
submit()
}}
style={{ marginTop: 24, width: '100%', maxWidth: 280, textAlign: 'left' }}
>
<label
htmlFor="ht-login-password"
style={{
display: 'block',
fontSize: 12,
fontWeight: 600,
color: 'var(--ht-ink-dim)',
marginBottom: 6,
}}
>
Password
</label>
{/* Native input, not ds/core/TextInput — see the module comment above. */}
<input
id="ht-login-password"
name="password"
type="password"
autoComplete="current-password"
// biome-ignore lint/a11y/noAutofocus: the one interactive element on a dedicated login screen.
autoFocus
required
disabled={isPending}
value={password}
onChange={(event) => {
setPassword(event.target.value)
if (error !== null) setError(null)
}}
style={{
width: '100%',
boxSizing: 'border-box',
fontFamily: 'var(--ht-sans)',
fontSize: 12.5,
color: 'var(--ht-ink)',
background: 'var(--ht-bg)',
border: '1px solid var(--ht-divider)',
borderRadius: 'var(--ht-radius-sm)',
padding: '8px 10px',
outline: 'none',
}}
/>

{error !== null && (
<div
role="alert"
aria-live="assertive"
style={{
marginTop: 8,
fontSize: 12.5,
fontWeight: 600,
color: 'var(--ht-critical)',
}}
>
{error}
</div>
)}
Comment on lines +127 to +168

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Announce rejected credentials to assistive technology.

The visual error is neither a live alert nor associated with the password field, so screen-reader users may not know why submission failed.

Proposed accessibility fix
 <input
   id="ht-login-password"
+  aria-invalid={error !== null}
+  aria-describedby={error !== null ? 'ht-login-error' : undefined}
   name="password"
   type="password"
   ...
 />

 {error !== null && (
   <div
+    id="ht-login-error"
+    role="alert"
     style={{
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<input
id="ht-login-password"
name="password"
type="password"
autoComplete="current-password"
// biome-ignore lint/a11y/noAutofocus: the one interactive element on a dedicated login screen.
autoFocus
required
disabled={isPending}
value={password}
onChange={(event) => {
setPassword(event.target.value)
if (error !== null) setError(null)
}}
style={{
width: '100%',
boxSizing: 'border-box',
fontFamily: 'var(--ht-sans)',
fontSize: 12.5,
color: 'var(--ht-ink)',
background: 'var(--ht-bg)',
border: '1px solid var(--ht-divider)',
borderRadius: 'var(--ht-radius-sm)',
padding: '8px 10px',
outline: 'none',
}}
/>
{error !== null && (
<div
style={{
marginTop: 8,
fontSize: 12.5,
fontWeight: 600,
color: 'var(--ht-critical)',
}}
>
{error}
</div>
)}
<input
id="ht-login-password"
aria-invalid={error !== null}
aria-describedby={error !== null ? 'ht-login-error' : undefined}
name="password"
type="password"
autoComplete="current-password"
// biome-ignore lint/a11y/noAutofocus: the one interactive element on a dedicated login screen.
autoFocus
required
disabled={isPending}
value={password}
onChange={(event) => {
setPassword(event.target.value)
if (error !== null) setError(null)
}}
style={{
width: '100%',
boxSizing: 'border-box',
fontFamily: 'var(--ht-sans)',
fontSize: 12.5,
color: 'var(--ht-ink)',
background: 'var(--ht-bg)',
border: '1px solid var(--ht-divider)',
borderRadius: 'var(--ht-radius-sm)',
padding: '8px 10px',
outline: 'none',
}}
/>
{error !== null && (
<div
id="ht-login-error"
role="alert"
style={{
marginTop: 8,
fontSize: 12.5,
fontWeight: 600,
color: 'var(--ht-critical)',
}}
>
{error}
</div>
)}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/LoginScreen.tsx` around lines 127 - 166, Update the
password field and the conditional error element in LoginScreen so rejected
credentials are announced to assistive technology: associate the field with the
error message and expose the message as an assertive alert, using a stable
matching identifier while preserving the existing visual error behavior.


<div style={{ marginTop: 16 }}>
<Button
variant="primary"
disabled={isPending || password.length === 0}
onClick={() => formRef.current?.requestSubmit()}
style={{ width: '100%', justifyContent: 'center' }}
>
{isPending ? 'Signing in…' : 'Sign in'}
</Button>
</div>
</form>
</div>
)
}
12 changes: 7 additions & 5 deletions web/src/components/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@

import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useEffect, useState, useTransition } from 'react'
import type { ConversationSummary } from '../lib/api-types'
import { logoutAction } from '../lib/auth-actions'
import { nameFromEmail, relativeTime } from '../lib/format'
import { Avatar } from './ds/core/Avatar'
import { DropdownMenu } from './ds/core/DropdownMenu'
Expand Down Expand Up @@ -115,6 +116,7 @@ export function TopBar({ recentOpen }: { recentOpen: ConversationSummary[] }) {
const showToast = useToast()
const { open: openShortcuts } = useShortcutsOverlay()
const [openMenu, setOpenMenu] = useState<MenuKey | null>(null)
const [, startLogoutTransition] = useTransition()

useEffect(() => {
if (openMenu === null) return
Expand Down Expand Up @@ -328,10 +330,10 @@ export function TopBar({ recentOpen }: { recentOpen: ConversationSummary[] }) {
destructive
onClick={() => {
setOpenMenu(null)
showToast({
title: 'No session to log out of',
detail:
'v1 authenticates the deployment, not a user — sessions arrive with multi-Agent.',
// logoutAction clears the session cookie and redirects to
// /login server-side (HT-51) — nothing left to do here.
startLogoutTransition(async () => {
await logoutAction()
})
}}
>
Expand Down
Loading
Loading