Skip to content
11 changes: 11 additions & 0 deletions scripts/dev-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,15 @@

import { createServer } from 'node:http'
import { createInboxApi } from '../src/api/index.js'
import { createPasswordAuthProvider } from '../src/auth/password-provider.js'
import type { AuthProvider } from '../src/auth/provider.js'
import { createPgliteDb } from '../src/db/client.js'
import { migrate } from '../src/db/migrate.js'
import { createDevEmailSender } from '../src/dev/dev-sender.js'
import { createHttpBridge } from '../src/dev/http-adapter.js'
import { seedDevData } from '../src/dev/seed.js'
import type { Keyring } from '../src/mail/reply-token.js'
import { createAgentStore } from '../src/store/agents.js'
import { createConversationStore } from '../src/store/conversations.js'

const PORT = Number(process.env.HT_DEV_PORT ?? 8787)
Expand All @@ -62,6 +65,13 @@ async function main(): Promise<void> {
const store = createConversationStore(db)
const sender = createDevEmailSender()

// Agents & Authentication (HT-54) — core, required by createInboxApi.
// No HELPTHREAD_UI_BASE_URL in this harness (there is no web dev server
// wired up here), so uiBaseUrl stays absent: sendInvite still creates the
// Agent but inviteSent is always false, matching a fresh, UI-less deploy.
const agentStore = createAgentStore(db)
const authProviders: AuthProvider[] = [createPasswordAuthProvider({ agentStore })]

let seededCount: number | undefined
if (DB_PATH === undefined) {
const seeded = await seedDevData({
Expand All @@ -82,6 +92,7 @@ async function main(): Promise<void> {
keyring: KEYRING,
mailDomain: MAIL_DOMAIN,
supportAddress: SUPPORT_ADDRESS,
agents: { store: agentStore, providers: authProviders },
})

const baseUrl = `http://127.0.0.1:${PORT}`
Expand Down
13 changes: 12 additions & 1 deletion specs/auth/agents-and-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,16 @@ Auth / bootstrap:
to me", gates admin controls) and treats `401` as "log in again."

Agents (management):
- **`GET /api/v1/agents`** (admin) → `Agent[]`.
Response envelopes (as built): a single Agent rides as `{ agent }` (`/setup`,
`/auth/verify`, `/auth/invite/accept`, `GET`/`PATCH /agents/{id}`), the roster as
`{ agents }`, and provider discovery as `{ providers, needsSetup }` — object envelopes
throughout, extensible without breaking clients, matching the wrapped shapes below.

- **`GET /api/v1/agents`** (any active Agent) → `{ agents: Agent[] }`. *(Amended at build time, HT-54:
was admin-only in the draft, but the assignee UI — any Agent may assign any Agent, §5 —
needs the roster to render names and offer choices; an admin-only list would make a
non-admin's assignee menu impossible. The roster carries no secrets (no identities, no
hashes). Every mutation below remains admin-gated.)*
- **`POST /api/v1/agents`** (admin) `{ name, email, role, sendInvite, password? }` → creates
an Agent (§8 provisioning): with `sendInvite`, `status='invited'` and no password; with
`password` (the admin-set fallback), a `password` identity and `status='active'` outright.
Expand Down Expand Up @@ -495,6 +504,8 @@ is retired (§8).

## Changelog

- **draft.4 (2026-07-18, HT-54 build):** `GET /agents` opened to any active Agent (was
admin-only) — the assignee UI needs the roster; mutations stay admin-gated (§6).
- **draft.3 (2026-07-18):** status is a closed lifecycle (CodeRabbit round 2): PATCH may
only toggle `active`↔`disabled`; `invited` exits solely via invite acceptance (or
delete/re-create); password writes on an `invited` Agent are refused (§6) — closing the
Expand Down
48 changes: 48 additions & 0 deletions src/api/acting-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Resolve the acting Agent from `X-Helpthread-Agent-Id` (HT-54;
* specs/auth/agents-and-auth.md §8) — the one place every handler that needs
* the acting Agent goes through, so the "load the row, re-check status"
* policy lives in exactly one function rather than being re-implemented per
* handler.
*
* The web derives this header ONLY from the verified session `sub`, never
* from client input (spec §5's guardrail) — the engine trusts it because the
* caller already holds the service Bearer token (`src/api/auth.ts`); this
* function's job is the engine-side half of that trust model: even a
* genuinely web-asserted header must be re-checked against the CURRENT row,
* since a signed session cookie can outlive an Agent being disabled or
* deleted (spec §8's "bounding a disabled Agent whose cookie is still
* valid" point — Edge middleware verifies the cookie but never touches the
* Agent store, so this engine-side check is the only place that can).
*
* `null` covers every failure uniformly (missing header, malformed/non-uuid
* value, no such Agent, or a non-`active` status) — callers map `null` to a
* generic `401`, never a more specific message that would leak which case
* applied.
*/

import type { AgentRecord, AgentStore } from '../store/agents.js'
import { isUuid } from './uuid.js'

/** The header the web asserts the session's verified `sub` under (spec §8). `Request.headers.get` is case-insensitive, so the exact casing here is cosmetic. */
export const ACTING_AGENT_HEADER = 'X-Helpthread-Agent-Id'

/**
* Resolve `request`'s acting Agent, or `null` if the header is absent,
* malformed, or names an Agent that is missing or not `status: 'active'`
* (an `invited` Agent is treated the same as `disabled` for acting
* purposes — spec: "only `active` can act"). Never throws.
*/
export async function resolveActingAgent(
request: Request,
store: AgentStore,
): Promise<AgentRecord | null> {
const header = request.headers.get(ACTING_AGENT_HEADER)
if (header === null) return null
const id = header.trim()
if (id.length === 0 || !isUuid(id)) return null

const agent = await store.getAgent(id)
if (agent === null || agent.status !== 'active') return null
return agent
}
Loading
Loading