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
55 changes: 49 additions & 6 deletions specs/api/agent-inbox-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,12 @@ interface ThreadView {
from: string // the message's From address; the support address for notes
bodyText: string | null
bodyHtml: string | null // ⚠ UNTRUSTED, UNSANITIZED — see §5
deliveryStatus: 'pending' | 'sent' | 'failed' | null // outbound only; null otherwise
deliveryStatus: 'pending' | 'sent' | 'failed' | null
// outbound only; null otherwise. HT-70: the invariant widens
// — an outbound thread's deliveryStatus is ALSO null while it
// is an unapproved or discarded draft (draftStatus below is
// 'awaiting_review' or 'discarded'); a draft becomes eligible
// for pending/sent/failed only once approved.
customerViewedAt: string | null
// v1.1: outbound only, and only when open tracking is
// enabled (§4g) — first time the customer viewed the reply;
Expand All @@ -80,6 +85,14 @@ interface ThreadView {
// attachment read-path deps (config-gated, absent by default
// — same posture as open tracking, §4g)
createdAt: string // ISO-8601
authorKind: 'customer' | 'agent' | 'assistant'
// HT-70 (specs/plugins/substrate-v1.md §2, §7): who authored
// this thread — 'customer' for inbound mail, 'agent' for
// human-authored outbound/notes, 'assistant' for an
// AI-authored draft (specs/plugins/substrate-v1.md §3, §6)
draftStatus: 'awaiting_review' | 'approved' | 'discarded' | null
// HT-70: a draft's lifecycle state; null for every non-draft
// thread (specs/plugins/substrate-v1.md §2, §6)
}

interface AttachmentView {
Expand Down Expand Up @@ -108,7 +121,13 @@ an identifier anywhere in this API.
**`preview`** is derived at read time, not stored: the most recent thread with a
non-null `bodyText` (any direction — notes included; this is an Agent-only surface),
whitespace collapsed to single spaces, trimmed, first 120 characters; `''` when no
thread has text.
thread has text. **HT-70:** `preview` and `threadCount` both IGNORE an unresolved or
discarded draft (`draftStatus IN ('awaiting_review', 'discarded')`) — a draft is not
conversation content until an Agent approves it, so it contributes to neither the
count nor the latest-body derivation. An `'approved'` draft (i.e. sent mail) counts and
can become the preview like any other outbound thread. Conversation detail (§3b) still
returns the draft ROW itself in `threads` regardless of its status — only the
summary-level `preview`/`threadCount` derivations exclude it.

Ids are **UUID strings**, verbatim as the store generates them — the uuid is canonical
and `number` is a human-facing convenience, not a surrogate key. There is no `customer`
Expand All @@ -127,10 +146,17 @@ 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.
**This is still the API's only auth model — with one addition (HT-70).** 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. HT-70 (specs/plugins/substrate-v1.md
§3) DOES add a genuine second credential class, checked ALONGSIDE the service Bearer
token, never replacing it: a per-Assistant token (`ht_asst_<assistantId>_<secret>`),
verified before routing under the same constant-time discipline (parse the embedded
id → single-row lookup → constant-time digest compare). An Assistant's capability set
is fixed and narrow (read conversations, create drafts, create notes — spec §3) and
enforced at one gate, distinct from every Agent-facing endpoint this document
describes.
- **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 @@ -179,6 +205,14 @@ Returns a `ConversationDetail` — the conversation plus its `threads`, oldest-f
not_found` if `{id}` is not a conversation (or is a `deleted` one — a deleted conversation
is indistinguishable from a nonexistent one to this API, on purpose).

**HT-70:** `threads` includes draft rows (`draftStatus` non-null) for Agent/service
callers, at every lifecycle stage — the timeline shows an `awaiting_review`/`discarded`
draft alongside real mail, distinguishable by `authorKind: 'assistant'` and
`draftStatus`. An Assistant caller reads the same endpoint and sees its own drafts
through it too (no separate read surface). Only the summary-level `preview`/
`threadCount` derivations exclude an unresolved/discarded draft (§2) — the full
`threads` array is never filtered by draft status.

## 4. Write paths

### 4a. `POST /api/v1/conversations/{id}/replies` — the Agent replies
Expand Down Expand Up @@ -424,6 +458,15 @@ above.

## 7. Changelog

- **v1.1 (HT-70).** Wire-contract amendments from specs/plugins/substrate-v1.md §7
(drafts kept in `threads` rather than a separate table): `ThreadView` gains
`authorKind` and `draftStatus` (§2); the `deliveryStatus` invariant widens (outbound
stays `null` while a draft is unapproved or discarded, §2); `preview`/`threadCount`
ignore an unresolved or discarded draft (§2); conversation detail (§3b) still returns
every draft row regardless of status; and §3's auth-model statement is amended — a
second, per-Assistant credential class now authenticates alongside the service Bearer
token, for the fixed, narrow Assistant capability set specs/plugins/substrate-v1.md §3
defines.
- **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
Expand Down
2 changes: 2 additions & 0 deletions src/api/agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { migrate } from '../db/migrate.js'
import type { Keyring } from '../mail/reply-token.js'
import type { EmailSender, OutboundEmail } from '../providers/index.js'
import { type AgentRecord, type AgentStore, createAgentStore } from '../store/agents.js'
import { createAssistantStore } from '../store/assistants.js'
import { createConversationStore } from '../store/conversations.js'
import { createMailboxStore, type MailboxStore } from '../store/mailboxes.js'
import { ENCRYPTION_KEY_BYTES } from '../store/token-crypto.js'
Expand Down Expand Up @@ -96,6 +97,7 @@ describe('Agents & Authentication API', () => {
store: createWebhookEndpointStore(db, WEBHOOKS_ENC_KEY),
queue: { async enqueue() {} },
},
assistants: { store: createAssistantStore(db) },
})
return { db, agentStore, mailboxStore, api, sent }
}
Expand Down
79 changes: 79 additions & 0 deletions src/api/assistant-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { randomUUID } from 'node:crypto'
import { afterEach, describe, expect, it } from 'vitest'
import { mintAssistantToken } from '../auth/assistant-token.js'
import { createPgliteDb, type Db } from '../db/client.js'
import { migrate } from '../db/migrate.js'
import { createAssistantStore } from '../store/assistants.js'
import { authenticateAssistantRequest } from './assistant-auth.js'

function req(authorization?: string): Request {
const headers: Record<string, string> = {}
if (authorization !== undefined) headers.authorization = authorization
return new Request('https://x.example.test/api/v1/conversations', { headers })
}

describe('authenticateAssistantRequest', () => {
let db: Db | undefined

afterEach(async () => {
await db?.close()
db = undefined
})

async function freshStoreWithAssistant(status: 'active' | 'disabled' = 'active') {
db = await createPgliteDb()
await migrate(db)
const store = createAssistantStore(db)
const id = randomUUID()
const minted = mintAssistantToken(id)
const assistant = await store.create({
id,
name: 'Draft Bot',
module: 'draft-reply',
tokenHash: minted.tokenHash,
})
if (status === 'disabled') {
await store.patch(id, { status: 'disabled' })
}
return { store, assistant, token: minted.token }
}

it('resolves the Assistant for a valid token', async () => {
const { store, assistant, token } = await freshStoreWithAssistant()
const resolved = await authenticateAssistantRequest(req(`Bearer ${token}`), store)
expect(resolved?.id).toBe(assistant.id)
})

it('returns null for a missing Authorization header', async () => {
const { store } = await freshStoreWithAssistant()
expect(await authenticateAssistantRequest(req(), store)).toBeNull()
})

it('returns null for a non-Bearer scheme', async () => {
const { store, token } = await freshStoreWithAssistant()
expect(await authenticateAssistantRequest(req(`Basic ${token}`), store)).toBeNull()
})

it('returns null for a token with the wrong secret (same assistantId)', async () => {
const { store, assistant } = await freshStoreWithAssistant()
const forged = `ht_asst_${assistant.id}_wrong-secret-value`
expect(await authenticateAssistantRequest(req(`Bearer ${forged}`), store)).toBeNull()
})

it('returns null for an unknown assistantId', async () => {
const { store } = await freshStoreWithAssistant()
const unknownId = '22222222-2222-4222-8222-222222222222'
const forged = `ht_asst_${unknownId}_some-secret`
expect(await authenticateAssistantRequest(req(`Bearer ${forged}`), store)).toBeNull()
})

it('returns null for a disabled Assistant, even with the correct secret', async () => {
const { store, token } = await freshStoreWithAssistant('disabled')
expect(await authenticateAssistantRequest(req(`Bearer ${token}`), store)).toBeNull()
})

it('returns null for a malformed token (not our shape)', async () => {
const { store } = await freshStoreWithAssistant()
expect(await authenticateAssistantRequest(req('Bearer not-our-token-shape'), store)).toBeNull()
})
})
55 changes: 55 additions & 0 deletions src/api/assistant-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Assistant bearer-token request authentication (HT-70; specs/plugins/
* substrate-v1.md §3, amending agent-inbox-v1.md §3/§7) — the SECOND
* credential class alongside the service Bearer token (`src/api/auth.ts`),
* checked ALONGSIDE it, never replacing it: `src/api/index.ts`'s pipeline
* tries the service token first, and only on a miss tries this.
*
* Verification sequence, exactly as spec §3 states it: parse the embedded
* assistantId out of the presented token → single-row lookup (no hash
* scan) → constant-time digest compare — before routing, so an Assistant's
* identity is resolved (or rejected) the same place/time the service token
* is.
*/

import {
constantTimeHashEquals,
hashAssistantSecret,
parseAssistantToken,
} from '../auth/assistant-token.js'
import type { AssistantRecord, AssistantStore } from '../store/assistants.js'

const BEARER_PREFIX = 'Bearer '

/**
* Resolve `request`'s Assistant, or `null` for anything that isn't a valid,
* active Assistant's token: a missing/malformed `Authorization` header, a
* value not shaped like `ht_asst_<id>_<secret>`, an unknown assistantId, a
* `disabled` Assistant, or a secret whose digest doesn't match the stored
* hash. Every rejection reason collapses to the same `null` — the caller
* (`src/api/index.ts`) maps it to the SAME generic `401` the service-token
* miss gets, never a more specific message that would distinguish "unknown
* id" from "wrong secret" from "disabled." Never throws.
*/
export async function authenticateAssistantRequest(
request: Request,
store: AssistantStore,
): Promise<AssistantRecord | null> {
const header = request.headers.get('authorization')
if (header === null || !header.startsWith(BEARER_PREFIX)) return null
const token = header.slice(BEARER_PREFIX.length)

const parsed = parseAssistantToken(token)
if (parsed === null) return null

// One-snapshot read (CodeRabbit #80): status and token_hash come from the
// SAME row read, so a disable or rotation can never be interleaved between
// separate status/hash queries and validate stale credentials.
const auth = await store.getForAuth(parsed.assistantId)
if (auth === null || auth.record.status !== 'active') return null

const providedHash = hashAssistantSecret(parsed.secret)
if (!constantTimeHashEquals(providedHash, auth.tokenHash)) return null

return auth.record
}
Loading
Loading