Skip to content

feat(engine): per-Agent identity, login & user management — engine layer (HT-54) - #70

Merged
zaridan merged 7 commits into
mainfrom
feat/ht-54-agents-auth-engine
Jul 18, 2026
Merged

feat(engine): per-Agent identity, login & user management — engine layer (HT-54)#70
zaridan merged 7 commits into
mainfrom
feat/ht-54-agents-auth-engine

Conversation

@zaridan

@zaridan zaridan commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Implements the engine half of specs/auth/agents-and-auth.md (HT-54). The web half follows in its own PR; the two breaking changes deploy coordinated (spec §10).

What's here

  • Migration 018agents, agent_auth_identities (one-password-per-Agent partial unique index), agent_mailbox_access (schema only, nothing consults it — §3.4 decision), and conversations.assignee graduates to assignee_agent_id uuid FK (breaking: 'me' rows become NULL by construction).
  • AgentStore — first-admin bootstrap, CRUD, last-admin invariant; every active-admin-reducing mutation serialized via pg_advisory_xact_lock inside one transaction (predicates stay as inner guards).
  • Auth seam (§4)AuthProvider interface + registry (wired at the composition root), PasswordAuthProvider (scrypt, per-identity salt, dummy-hash timing equalization), invite tokens (hti. domain-separated from ht./gmc., 72h TTL, one-time via the atomic invited→active transition).
  • API (§6)/setup, /auth/providers, /auth/verify (uniform 401, no enumeration), /auth/me, /agents CRUD + /password + /invite + /auth/invite/accept; acting-Agent (X-Helpthread-Agent-Id) enforcement on /agents/*, /auth/me, and PUT .../assignee (breaking: body is now { assigneeAgentId }).
  • Invite email rides the core EmailSender transport directly — never sendReply (no phantom threads).

Decisions for the maintainer's eye (defaults taken, flagged, not silent)

  1. GET /agents is open to any active Agent (spec draft said admin-only; amended in this PR's spec commit + changelog draft.4). The assignee UI needs the roster — a non-admin couldn't render an assignee's name otherwise. Mutations stay admin-only.
  2. The roster returns the full Agent record (email/role/status) to non-admins, not a trimmed projection — normal for a helpdesk team, simplest; trim later if you want.
  3. Assignee may target a non-active Agent (spec silent; FreeScout allows it).
  4. Invite TTL 72h; password policy 8–256 chars; invite-email subject uses the mail domain (no deployment-name config exists).
  5. sendInvite on a deploy with no HELPTHREAD_UI_BASE_URL creates the Agent and returns inviteSent:false (recoverable via configure-then-resend); the web UI will surface it.

Review & verification

  • Implemented from the spec (Sonnet), then two independent adversarial reviews (correctness/concurrency; security/charter) against spec+charter+codebase — verdicts SHIP and FIX-THEN-SHIP; both fixes applied (login-password length cap before scrypt on the one pre-session rate-limit-free path; login-specific 401 copy).
  • Advisory-lock soundness verified down to PostgresDb.transaction (one pinned pooled client; xact-scoped locks — the flavor that survives Supabase's transaction-mode pooler).
  • Gates (sequential, real exit codes): typecheck ✅ lint ✅ vitest run1019 tests / 50 files, incl. migration-upgrade path over 1..17, authz matrix, uniform-401, token domain-separation, last-admin concurrency guards, invite one-time replay.
  • End-to-end HTTP verification: DONE against the dev harness — full authz/lifecycle matrix (20 checks incl. uniform-401 byte-identity, last-admin 409s, disabled-actor lockout, breaking assignee shape) all as specified.
  • CodeRabbit round 1: 7 findings applied (constraint, FK index, decode ceilings, https-only invite origin, FK-race translation, lock-ordering test, spec envelopes), 2 rebutted with reasoning (async scrypt — deferred alongside HT-53 rate limiting; two-connection race test — not reproducible on single-connection PGlite).
  • Follow-up noted: consider async crypto.scrypt if/when a public login sees real concurrency (with HT-53).

Charter notes: no new dependencies; no copyleft-derived code; Agents/Assistants vocabulary enforced; mail semantics untouched (invites use the sender transport only).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added Agents & Authentication API with password-based setup, login verification, session identity (“me”), and invite acceptance.
    • Added agent management (admin-only create/update/delete, password changes, resend invites) plus active-agent roster access for non-admins.
    • Updated conversation assignee to assign by specific agent identity, with acting-agent enforcement.
  • Bug Fixes
    • Standardized authentication/invitation failures with consistent 401 behavior and uniform error envelopes for malformed/invalid cases.
  • Documentation
    • Updated authorization guidance and changelog notes for roster visibility and the updated agents/auth API contract.

zaridan and others added 6 commits July 18, 2026 14:16
Adds agents, agent_auth_identities (with the one-password-per-agent
partial unique index), and agent_mailbox_access (schema-only, no
behavior yet) per specs/auth/agents-and-auth.md §3, plus the
conversations.assignee -> assignee_agent_id breaking swap (§3.3).

AgentStore implements the last-admin invariant (§5) with the same
pg_advisory_xact_lock discipline migrate.ts already uses for its own
cross-instance race, on a distinct lock key shared between
createFirstAdmin's zero-Agents guard and the role/status mutation
guard in updateAgent/deleteAgent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- src/auth/password-hash.ts: scrypt (node:crypto), per-identity random
  salt, fixed cost params, self-describing encoding; DUMMY_HASH for
  timing-comparable rejection of unknown emails (spec §9).
- src/auth/provider.ts + password-provider.ts: the AuthProvider seam
  (spec §4) and the core's one implementation, dispatched by email +
  scrypt-verified password, uniform null on any failure (unknown
  email, wrong password, non-active Agent).
- src/auth/invite-token.ts: hti.-prefixed signed invite tokens,
  mirroring gmail-connect.ts's gmc. state-token pattern off the same
  Keyring, 72h TTL. One-time-ness comes from AgentStore.acceptInvite's
  atomic status transition, not the token itself.
- src/auth/invite-email.ts: builds the invite OutboundEmail via the
  EmailSender transport directly (never sendReply — an invite has no
  conversation to thread against).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New src/api/agents.ts handlers for the full auth-bootstrap + Agent
management surface (spec §6): /auth/providers, /setup, /auth/verify,
/auth/me, /auth/invite/accept, and /agents CRUD + /password +
/invite. New src/api/acting-agent.ts resolves the X-Helpthread-Agent-Id
header to the current, active Agent row (spec §8) — required on every
/agents/* route, /auth/me, and PUT .../assignee; missing/disabled maps
to a uniform 401 everywhere.

GET /agents is open to any ACTIVE acting Agent (not admin-gated) per
the coordinator's amendment: the inbox's assignee picker needs the
roster to render names for a non-admin's own assignee menu.

Breaking (spec §3.3/§10, coordinated with the assignee_agent_id
migration): PUT /conversations/{id}/assignee's body is now
{ assigneeAgentId: uuid | null } (was { assignee: 'me' | null }); the
old shape is a plain 400 now. The endpoint requires the acting-Agent
header; a non-null assigneeAgentId must name an existing Agent or the
request is 400 validation_failed.

InboxApiDeps.agents is REQUIRED (agents/auth is core, not an
absent-by-default feature like openTracking/gmailPush).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ss (HT-54)

- config.ts: optional HELPTHREAD_UI_BASE_URL (uiBaseUrl) — absent by
  default, validated as a bare http(s) origin when set. Invite email
  deps are simply absent without it; the admin-set-password path
  still works on any deployment.
- root.ts: builds createAgentStore(db) and the provider registry
  [createPasswordAuthProvider], wires them into createInboxApi's new
  required `agents` deps.
- scripts/dev-api.ts: same required deps for the local dev harness
  (no UI base URL in this harness, so invites always report
  inviteSent: false — the admin-set-password path is what the harness
  exercises).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ignee UI (HT-54)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…1 copy (HT-54)

Review fixes (two-lens adversarial review):
- /auth/verify now rejects >256-char candidates before any KDF work — the
  one pre-session, rate-limit-free entry point could otherwise be fed
  unbounded scrypt input; the cap constant moves next to the KDF
  (password-hash.ts) so the API validation and login path can never drift
- /auth/verify's uniform 401 gets sign-in phrasing ('Invalid email or
  password.') instead of the acting-Agent header copy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds multi-agent persistence, password and invite authentication, authenticated agent-management routes, acting-agent authorization, and agent-based conversation assignment. The local API harness and composition root now inject agent stores and authentication providers, with coverage spanning migrations, stores, routing, handlers, and end-to-end behavior.

Changes

Agents, authentication, and assignment

Layer / File(s) Summary
Agent schema and persistence
src/db/*, src/store/agents.*
Adds agent, identity, mailbox-access, and agent-assignment schema and persistence, including invite lifecycle and last-active-admin protection.
Password and invite authentication
src/auth/*
Adds provider contracts, scrypt password hashing, invite tokens, invite email construction, and password-provider authentication.
Agents and authentication handlers
src/api/acting-agent.ts, src/api/agents.*
Adds setup, authentication, roster, agent CRUD, password, invite, and invite-acceptance handlers with acting-agent authorization and validation.
Routing and application wiring
src/api/index.*, src/api/router.*, src/composition/*, scripts/dev-api.ts
Registers the new routes and injects agent stores, providers, UI configuration, and handler dependencies into API composition.
Agent-based conversation assignment
src/api/conversations.ts, src/store/conversations.ts
Migrates assignee requests and responses from assignee: 'me' to validated assigneeAgentId values and requires an active acting agent.

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

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant InboxApi
  participant AuthProvider
  participant AgentStore
  Client->>InboxApi: POST /auth/verify
  InboxApi->>AuthProvider: authenticate(AuthAttempt)
  AuthProvider->>AgentStore: getPasswordIdentityByEmail()
  AgentStore-->>AuthProvider: PasswordIdentity
  AuthProvider->>AgentStore: getAgent()
  AgentStore-->>AuthProvider: active AgentRecord
  AuthProvider-->>InboxApi: VerifiedIdentity
  InboxApi-->>Client: authentication 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 73.33% 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
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main engine-layer change: per-Agent identity, authentication, and user management for HT-54.
✨ 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-54-agents-auth-engine

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: 9

🧹 Nitpick comments (1)
src/auth/password-hash.test.ts (1)

51-57: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Prove that DUMMY_HASH is decodable and actually runs scrypt.

These assertions also pass for a malformed four-segment value because verifyPassword returns false on decode failure. Generate DUMMY_HASH from a documented test preimage and assert that preimage verifies successfully; keep the unrelated-password assertion as the rejection check.

As per coding guidelines, “continue until the result is verified rather than merely plausible.”

🤖 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/auth/password-hash.test.ts` around lines 51 - 57, Update the DUMMY_HASH
test to use a documented test preimage and assert that verifyPassword accepts
that preimage, proving the hash is decodable and executes scrypt. Retain the
existing unrelated-password assertion to confirm rejection, while keeping the
split-length and scrypt-prefix checks in the test.

Source: Coding guidelines

🤖 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 `@src/api/agents.ts`:
- Around line 281-282: Update the GET /api/v1/agents handler around listAgents
to return the mapped Agent array directly, matching the documented Agent[] wire
contract rather than wrapping it in an agents property. Add an exact
response-shape test that first fails against the current wrapper and verifies
the endpoint returns only the expected array.

In `@src/api/conversations.ts`:
- Around line 697-703: Update the assignee validation and assignment flow in the
conversation handler around getAgent and setConversationAssignee to handle an
Agent deleted between those operations. Catch the store’s known foreign-key
violation and return the documented 400 validation_failed response for
assigneeAgentId; preserve existing behavior for successful assignments and
unrelated errors.

In `@src/auth/password-hash.ts`:
- Around line 119-122: Replace synchronous scrypt usage in hashPassword() and
verifyPassword() with async crypto.scrypt, updating both functions to return
Promises while preserving their current hashing and verification behavior.
Propagate the async flow through every caller in setup, login, password reset,
and invite acceptance, awaiting the results before continuing.
- Around line 89-111: Validate the parsed scrypt parameters in the hash-parsing
flow before returning from the relevant function: cap n, r, and p to the
established safe work-factor bounds, and reject decoded hash values whose length
exceeds the expected derived-key size. Update the validation around the existing
n/r/p checks and salt/hash decoding so verifyPassword cannot pass
attacker-controlled resource requirements to scryptSync.

In `@src/composition/config.ts`:
- Around line 290-295: Update the HELPTHREAD_UI_BASE_URL validation around
parsed.protocol to require HTTPS for all non-loopback hosts, while allowing HTTP
only for explicit loopback hosts such as localhost or 127.0.0.1. Preserve
rejection of other protocols, and add tests covering HTTPS acceptance,
non-loopback HTTP rejection, and permitted loopback HTTP development URLs.

In `@src/db/migrate.ts`:
- Line 910: Add an index for the conversations.assignee_agent_id column
alongside the ALTER TABLE migration, using the project's established
migration/index naming conventions so agent deletions can efficiently update
referencing conversations.
- Around line 892-903: Update the agent_auth_identities schema to enforce that
rows with provider = 'password' must have a non-null secret_hash, while
preserving nullable secret_hash for other providers. Add a regression test that
attempts to insert a password identity without secret_hash and verifies the
database rejects it.

In `@src/store/agents.test.ts`:
- Around line 67-81: Replace the raw pg_advisory_xact_lock assertion with
coverage of createFirstAdmin: use an instrumented Db to verify the advisory lock
is acquired before the zero-Agents check, then add a two-connection concurrent
bootstrap test asserting exactly one Agent is created. Reuse the existing
freshStore and createFirstAdmin setup, while keeping the test focused on
ordering and the single-winner race behavior.

In `@src/store/conversations.ts`:
- Around line 489-503: Update setConversationAssignee so agent existence
validation and the assignee update occur atomically, preventing deletion between
validation and write from causing an uncontrolled FK error. Use a transaction
with appropriate locking, or explicitly translate the FK violation into the
documented validation response, while preserving null release and
missing/deleted conversation behavior.

---

Nitpick comments:
In `@src/auth/password-hash.test.ts`:
- Around line 51-57: Update the DUMMY_HASH test to use a documented test
preimage and assert that verifyPassword accepts that preimage, proving the hash
is decodable and executes scrypt. Retain the existing unrelated-password
assertion to confirm rejection, while keeping the split-length and scrypt-prefix
checks in the test.
🪄 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: 280340aa-5dc3-4821-ba01-e49241df2069

📥 Commits

Reviewing files that changed from the base of the PR and between 1a32a0f and 1ff80d4.

📒 Files selected for processing (29)
  • scripts/dev-api.ts
  • specs/auth/agents-and-auth.md
  • src/api/acting-agent.ts
  • src/api/agents.test.ts
  • src/api/agents.ts
  • src/api/conversations.ts
  • src/api/index.test.ts
  • src/api/index.ts
  • src/api/router.test.ts
  • src/api/router.ts
  • src/auth/invite-email.test.ts
  • src/auth/invite-email.ts
  • src/auth/invite-token.test.ts
  • src/auth/invite-token.ts
  • src/auth/password-hash.test.ts
  • src/auth/password-hash.ts
  • src/auth/password-provider.test.ts
  • src/auth/password-provider.ts
  • src/auth/provider.ts
  • src/composition/config.test.ts
  • src/composition/config.ts
  • src/composition/root.ts
  • src/db/migrate.test.ts
  • src/db/migrate.ts
  • src/db/postgres.test.ts
  • src/store/agents.test.ts
  • src/store/agents.ts
  • src/store/conversations.test.ts
  • src/store/conversations.ts

Comment thread src/api/agents.ts
Comment thread src/api/conversations.ts
Comment thread src/auth/password-hash.ts
Comment thread src/auth/password-hash.ts
Comment thread src/composition/config.ts
Comment thread src/db/migrate.ts
Comment thread src/db/migrate.ts
Comment thread src/store/agents.test.ts
Comment thread src/store/conversations.ts
- migration 018: CHECK (password identities carry a secret_hash); index on
  conversations.assignee_agent_id (FK without an index full-scans on delete)
- password-hash decode: ceilings on embedded N/r/p and salt/hash sizes — a
  corrupted or hostile stored value can't buy unbounded scrypt work
- config: HELPTHREAD_UI_BASE_URL refuses plain http except loopback (invite
  links carry a signed credential)
- assignee FK race: agent deleted between existence check and UPDATE now
  maps to 400 validation_failed via a translated store outcome, not a 500
- agents store test: instrumented-Db assertion that the advisory lock is
  acquired BEFORE the zero-Agents check inside createFirstAdmin
- spec: §6 documents the as-built response envelopes ({agent}/{agents})

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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