Skip to content

feat(api): agent-inbox API v1 — foundation + read paths (HT-17) - #14

Merged
zaridan merged 2 commits into
mainfrom
feat/ht-17-agent-inbox-api
Jul 11, 2026
Merged

feat(api): agent-inbox API v1 — foundation + read paths (HT-17)#14
zaridan merged 2 commits into
mainfrom
feat/ht-17-agent-inbox-api

Conversation

@zaridan

@zaridan zaridan commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Closes HT-17. Helpthread's first public API — the agent/inbox surface (mail lands → see it → reply), designed native now that FreeScout wire-compat is dropped (zero customers, no cutover pressure). Framework-agnostic Request → Response; a Vercel adapter is a later thin wrapper. This PR is the foundation + read paths; HT-18 adds the write paths (reply, close/reopen).

What's here

  • specs/api/agent-inbox-v1.md — the native domain model + contract (UUID ids, our own open/closed vocab, no _embedded, no integer surrogates). Deletes the superseded conversations-v1.md.
  • src/api/ (framework-agnostic): constant-time Bearer auth that runs before routing (an unauthenticated caller learns nothing about which routes exist), native { error: { code, message } } envelope, Cache-Control: no-store on every response, keyset-cursor pagination.
    • GET /api/v1/conversations — inbox list, newest-activity-first, status filter, cursor paging; a deleted conversation never appears.
    • GET /api/v1/conversations/{id} — conversation + threads; missing / deleted / malformed-id all → identical 404.
  • src/storelistConversations (keyset by updated_at,id) and getConversation({ includeDeleted }).

Codex adversarial review (auth/leak boundary — standing rule)

Codex returned DON'T-SHIP with 4 findings; all fixed with tests:

  • forged cursor / non-UUID path id would reach the uuid column and throw an uncaught 500 → shape-guard UUIDs at the boundary (bad cursor → clean 400, bad id → 404).
  • no top-level try/catch → a handler/store throw escaped the error envelope + no-store and let the host decide the 500 shape (stack leak risk) → wrapped dispatch, generic 500 that leaks nothing. (Fixing this surfaced a return await subtlety — a bare return promise escapes the catch — which a test caught.)
  • empty apiToken made Authorization: Bearer authenticate → fail closed at construction (minimum token length).
  • deleted-vs-nonexistent latency side-channel (threads loaded for a deleted row before the 404) → exclude deleted in the conversation lookup, before threads load.

Testing

Real PGlite + real Request objects: auth (missing/scheme/wrong-token/empty-token), list ordering/filter/pagination/deleted-exclusion/bad-cursor, get incl. deleted→404 and non-UUID→404, the 500 envelope+no-store path, and cross-cutting conventions. 193 tests pass, typecheck + Biome clean.

Security-critical (auth) so a Codex confirm pass on the fixes is running; I'll note the verdict.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Implemented Agent Inbox API v1 conversation endpoints with authenticated listing and detail retrieval.
    • Added stable status filtering, validated limit, and opaque cursor pagination; standardized thread ordering and wire shaping.
    • Enforced consistent API behavior for 401/404/405, including Allow headers and generic error envelopes with Cache-Control: no-store.
  • Documentation
    • Added the complete Agent Inbox API v1 specification; removed the previous draft.
  • Tests
    • Added/expanded suites covering authentication hardening, routing behavior, cursor encoding/decoding, validation, pagination correctness, ordering, and error handling.

Helpthread's first public API, designed native (FreeScout wire-compat
dropped — zero customers, no cutover pressure). Framework-agnostic
Request→Response; a Vercel adapter is a later thin wrapper.

- specs/api/agent-inbox-v1.md — native domain model + contract; deletes the
  superseded conversations-v1.md.
- src/api/: constant-time Bearer auth (runs BEFORE routing so nothing leaks
  to an unauthenticated caller), native { error: { code, message } } envelope,
  Cache-Control: no-store on every response, keyset-cursor pagination.
  GET /api/v1/conversations (inbox list, newest-activity-first, status
  filter) + GET /api/v1/conversations/{id} (conversation + threads).
- src/store: listConversations (keyset by updated_at,id; deleted never
  surfaced); getConversation gains { includeDeleted } to exclude deleted at
  the lookup on the public path.

Codex adversarial review (auth/leak boundary, standing rule) → all fixed:
- forged cursor / non-UUID path id would hit the uuid column and throw an
  uncaught 500 → shape-guard UUIDs at the boundary (400 for a bad cursor,
  404 for a bad id);
- no top-level try/catch → a handler throw escaped the envelope + no-store →
  wrapped dispatch, generic 500, leaks nothing (and used `return await` so
  async rejections are actually caught);
- an empty apiToken made `Bearer ` authenticate → fail closed at
  construction (min token length);
- deleted-vs-nonexistent latency side-channel → exclude deleted in the
  conversation lookup, before threads load.

193 tests pass; typecheck + Biome clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqG66PPZreBrj17VbAqe3b
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b87a4788-43b9-48b3-8324-47acdb23c81e

📥 Commits

Reviewing files that changed from the base of the PR and between c4d2b76 and 92f11ed.

📒 Files selected for processing (3)
  • specs/api/agent-inbox-v1.md
  • src/api/auth.ts
  • src/api/index.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/api/auth.ts
  • specs/api/agent-inbox-v1.md
  • src/api/index.ts

📝 Walkthrough

Walkthrough

Adds the Agent Inbox API v1 specification and implements authenticated conversation listing and retrieval with pagination, routing, standardized responses, UUID validation, and conversation-store support. The previous draft Conversations API specification is removed.

Changes

Agent Inbox API

Layer / File(s) Summary
API contracts and pagination primitives
specs/api/agent-inbox-v1.md, specs/api/conversations-v1.md, src/api/responses.ts, src/api/cursor.ts, src/api/cursor.test.ts, src/api/uuid.ts
Defines API models, headers, errors, read and write endpoint contracts, security notes, and v1 non-goals; removes the previous draft specification; adds JSON response helpers, opaque cursor handling, and UUID validation.
Authentication and request routing
src/api/auth.ts, src/api/auth.test.ts, src/api/router.ts, src/api/router.test.ts, src/api/index.ts, src/api/index.test.ts
Adds constant-time Bearer-token authentication, route matching, fail-closed token validation, endpoint dispatch, standardized error handling, and coverage for authentication, routing, and response conventions.
Conversation listing and retrieval
src/api/conversations.ts, src/store/conversations.ts, src/store/conversations.test.ts, src/store/index.ts
Adds conversation list and detail handlers, status and limit validation, keyset pagination, deleted-record filtering, thread shaping, stable ordering, thread counts, and store-layer tests.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant createInboxApi
  participant authenticateRequest
  participant matchRoute
  participant ConversationStore
  Client->>createInboxApi: Send API request
  createInboxApi->>authenticateRequest: Check Bearer token
  authenticateRequest-->>createInboxApi: Return authentication result
  createInboxApi->>matchRoute: Match route and method
  matchRoute-->>createInboxApi: Return route outcome
  createInboxApi->>ConversationStore: Query conversations
  ConversationStore-->>createInboxApi: Return summaries or details
  createInboxApi-->>Client: Return JSON response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the PR’s main scope: Agent Inbox API v1 foundation and read-path implementation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ht-17-agent-inbox-api

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🤖 Prompt for all review comments with 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.

Inline comments:
In `@specs/api/agent-inbox-v1.md`:
- Around line 10-17: The documentation uses “agent” ambiguously for human
support staff; consistently capitalize and use “Agent” for human roles
throughout the document, including the noted references, while reserving
“Assistant” exclusively for AI actors.

In `@src/api/auth.ts`:
- Around line 5-8: Update the terminology in the documentation comment near the
v1 credential description: replace “per-agent” with “per-Agent” to consistently
distinguish human Agents from AI Assistants.
- Around line 40-45: Update the authorization-header validation in the relevant
auth helper to recognize the Bearer scheme case-insensitively while preserving
case-sensitive token comparison via constantTimeEquals. Normalize or otherwise
compare only the scheme portion before slicing the token, and add regression
tests covering lowercase “bearer” and multiple whitespace cases.
- Line 27: Remove the node:crypto import from src/api/auth.ts and replace
timingSafeEqual usage with a Web Crypto–compatible constant-time comparison,
preserving equal-length and mismatch handling; alternatively, explicitly mark
the API Node-only and remove its Edge compatibility declaration in
src/api/index.ts.

In `@src/api/cursor.test.ts`:
- Around line 52-55: Update the bad-date fixture in the decodeCursor test to use
a valid UUID for the payload’s i field while keeping u as an invalid date,
ensuring validation reaches the date-parsing path.

In `@src/api/responses.ts`:
- Around line 34-57: Extend the shared response helpers json and apiError to
accept optional internal extra headers, preserving existing callers, and ensure
every 401 response includes the WWW-Authenticate: Bearer header. Update API
specification §3 to document this required Bearer challenge for 401 responses.
- Around line 47-53: Update the API response documentation near the message
safety contract to refer to the human token holder as an “Agent” and the AI
actor as an “Assistant,” replacing the current role terminology while preserving
the existing security guidance.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e47a3f9d-71ad-447b-9020-d3ef3f38d7ca

📥 Commits

Reviewing files that changed from the base of the PR and between 3178dd0 and c4d2b76.

📒 Files selected for processing (16)
  • specs/api/agent-inbox-v1.md
  • specs/api/conversations-v1.md
  • src/api/auth.test.ts
  • src/api/auth.ts
  • src/api/conversations.ts
  • src/api/cursor.test.ts
  • src/api/cursor.ts
  • src/api/index.test.ts
  • src/api/index.ts
  • src/api/responses.ts
  • src/api/router.test.ts
  • src/api/router.ts
  • src/api/uuid.ts
  • src/store/conversations.test.ts
  • src/store/conversations.ts
  • src/store/index.ts
💤 Files with no reviewable changes (1)
  • specs/api/conversations-v1.md

Comment thread specs/api/agent-inbox-v1.md Outdated
Comment thread src/api/auth.ts
Comment thread src/api/auth.ts
Comment thread src/api/auth.ts
Comment on lines +40 to +45
if (header === null || !header.startsWith(BEARER_PREFIX)) {
return false
}

const provided = header.slice(BEARER_PREFIX.length)
return constantTimeEquals(provided, token)

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

Accept case-insensitive Bearer schemes.

startsWith('Bearer ') rejects valid bearer <token> credentials. HTTP authentication scheme identifiers are case-insensitive; keep the extracted token comparison case-sensitive. Add lowercase-bearer and multiple-SP regression cases. (datatracker.ietf.org)

Proposed fix
-const BEARER_PREFIX = 'Bearer '
+const BEARER_HEADER = /^Bearer +(.+)$/i

 export function authenticateRequest(request: Request, token: string): boolean {
   const header = request.headers.get('authorization')
-  if (header === null || !header.startsWith(BEARER_PREFIX)) {
+  const match = header === null ? null : BEARER_HEADER.exec(header)
+  if (match === null) {
     return false
   }

-  const provided = header.slice(BEARER_PREFIX.length)
+  const provided = match[1]
   return constantTimeEquals(provided, token)
 }
📝 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
if (header === null || !header.startsWith(BEARER_PREFIX)) {
return false
}
const provided = header.slice(BEARER_PREFIX.length)
return constantTimeEquals(provided, token)
const BEARER_HEADER = /^Bearer +(.+)$/i
export function authenticateRequest(request: Request, token: string): boolean {
const header = request.headers.get('authorization')
const match = header === null ? null : BEARER_HEADER.exec(header)
if (match === null) {
return false
}
const provided = match[1]
return constantTimeEquals(provided, token)
}
🤖 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 `@src/api/auth.ts` around lines 40 - 45, Update the authorization-header
validation in the relevant auth helper to recognize the Bearer scheme
case-insensitively while preserving case-sensitive token comparison via
constantTimeEquals. Normalize or otherwise compare only the scheme portion
before slicing the token, and add regression tests covering lowercase “bearer”
and multiple whitespace cases.

Comment thread src/api/cursor.test.ts
Comment on lines +52 to +55
const badDate = Buffer.from(JSON.stringify({ u: 'not-a-date', i: 'conv-1' }), 'utf8').toString(
'base64url',
)
expect(decodeCursor(badDate)).toBeNull()

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

Use a valid UUID in the bad-date fixture.

'conv-1' is rejected by isUuid before new Date(payload.u) runs, so this test does not cover invalid-date handling.

Proposed fix
- JSON.stringify({ u: 'not-a-date', i: 'conv-1' })
+ JSON.stringify({ u: 'not-a-date', i: UUID })
📝 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
const badDate = Buffer.from(JSON.stringify({ u: 'not-a-date', i: 'conv-1' }), 'utf8').toString(
'base64url',
)
expect(decodeCursor(badDate)).toBeNull()
const badDate = Buffer.from(JSON.stringify({ u: 'not-a-date', i: UUID }), 'utf8').toString(
'base64url',
)
expect(decodeCursor(badDate)).toBeNull()
🤖 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 `@src/api/cursor.test.ts` around lines 52 - 55, Update the bad-date fixture in
the decodeCursor test to use a valid UUID for the payload’s i field while
keeping u as an invalid date, ensuring validation reaches the date-parsing path.

Comment thread src/api/responses.ts
Comment on lines +34 to +57
export function json(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
})
}

/**
* Build an error `Response` in the standard envelope (spec §3).
*
* `message` MUST be user-safe: never a stack trace, never a raw SQL error,
* never an upstream provider's response body, never an id the caller didn't
* already supply themselves. It is shown to whoever is holding the service
* Bearer token — today that's the operator, but the contract is written for
* the day an AI assistant or a less-trusted integration holds it instead.
* When in doubt, write a generic message and let server-side logging (not
* this response) carry the detail.
*/
export function apiError(status: number, code: string, message: string): Response {
const body: ApiError = { error: { code, message } }
return json(status, body)

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

Add the required Bearer challenge to 401 responses.

apiError cannot attach WWW-Authenticate, so 401s produced through the shared helper are non-compliant. HTTP requires every 401 response to include at least one authentication challenge. (rfc-editor.org) Extend json/apiError with internal extra headers and emit WWW-Authenticate: Bearer for status 401; document it in API spec §3 as well.

🤖 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 `@src/api/responses.ts` around lines 34 - 57, Extend the shared response
helpers json and apiError to accept optional internal extra headers, preserving
existing callers, and ensure every 401 response includes the WWW-Authenticate:
Bearer header. Update API specification §3 to document this required Bearer
challenge for 401 responses.

Comment thread src/api/responses.ts
Comment on lines +47 to +53
* `message` MUST be user-safe: never a stack trace, never a raw SQL error,
* never an upstream provider's response body, never an id the caller didn't
* already supply themselves. It is shown to whoever is holding the service
* Bearer token — today that's the operator, but the contract is written for
* the day an AI assistant or a less-trusted integration holds it instead.
* When in doubt, write a generic message and let server-side logging (not
* this response) carry the detail.

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 role names in API prose.

Call the human token holder an Agent and the AI actor an Assistant.

As per coding guidelines, human support staff are Agents, and AI actors are Assistants; never conflate them in schema, code, documentation, or prose.

🤖 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 `@src/api/responses.ts` around lines 47 - 53, Update the API response
documentation near the message safety contract to refer to the human token
holder as an “Agent” and the AI actor as an “Assistant,” replacing the current
role terminology while preserving the existing security guidance.

Source: Coding guidelines

Address CodeRabbit on #14:
- index.ts (Major): drop the misleading "Vercel Edge Function" claim. The
  engine is Node-bound anyway — reply-token.ts uses node:crypto's createHmac
  (no sync Web Crypto equivalent), so Edge was never achievable; auth.ts's
  node:crypto import is that same constraint, not a new one. Documented as
  Node-runtime; the Request/Response shape stays portable across Node hosts.
- Use the project's defined vocabulary (CLAUDE.md): human support staff are
  'Agent', not lowercase 'agent' — corrected across agent-inbox-v1.md and
  auth.ts (Minor).

193 tests pass; typecheck + Biome clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqG66PPZreBrj17VbAqe3b
@zaridan

zaridan commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

@CodeRabbit resolve all of the comments that are good to go

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
gh api graphql -f query='
query {
  repository(owner: "Helpthread", name: "helpthread") {
    pullRequest(number: 14) {
      reviewThreads(first: 100) {
        nodes {
          id
          isResolved
          isOutdated
          comments(first: 20) {
            nodes {
              id
              databaseId
              author { login }
              body
              createdAt
            }
          }
        }
      }
    }
  }
}' --jq '.data.repository.pullRequest.reviewThreads.nodes'

Length of output: 17817


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "=== auth.ts ==="
cat -n src/api/auth.ts
echo "=== cursor.test.ts (bad date section) ==="
grep -n "not-a-date\|conv-1\|UUID" src/api/cursor.test.ts
echo "=== responses.ts ==="
cat -n src/api/responses.ts

Length of output: 6589


@zaridan I checked all currently unresolved review threads against the latest code, and none of them have actually been addressed yet, so I don't think any are safe to resolve right now:

  1. Case-insensitive Bearer scheme (src/api/auth.ts, Lines 40-45) — still uses header.startsWith('Bearer '), which rejects bearer <token>. Not fixed.
  2. Bad-date fixture uses non-UUID (src/api/cursor.test.ts, Line 52) — still uses i: 'conv-1' instead of a valid UUID, so isUuid short-circuits before the date-parsing path is ever exercised. Not fixed.
  3. Missing WWW-Authenticate on 401s (src/api/responses.ts) — json/apiError still don't support extra headers, so 401 responses can't emit the required WWW-Authenticate: Bearer challenge. Not fixed.
  4. Agent/Assistant terminology (src/api/responses.ts, Lines 47-53) — the doc comment still says "the operator" and "an AI assistant" rather than the standardized Agent/Assistant terms. Not fixed.

The other threads (terminology fixes in specs/api/agent-inbox-v1.md and src/api/auth.ts, and the Edge-runtime node:crypto discussion) are already marked resolved — those were genuinely addressed in commit 92f11ed or confirmed by you directly.

Since none of the four above show corresponding code changes, I'll leave them open rather than resolve them. Let me know if you'd like me to go ahead and resolve any of them anyway, or if you plan to address them in a follow-up commit.

@zaridan
zaridan merged commit 151384c into main Jul 11, 2026
5 checks passed
@zaridan
zaridan deleted the feat/ht-17-agent-inbox-api branch July 11, 2026 00:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant