Skip to content

feat(dev): make the dev harness able to actually deliver a webhook - #183

Merged
zaridan merged 2 commits into
mainfrom
feat/ht-dev-webhook-delivery
Aug 3, 2026
Merged

feat(dev): make the dev harness able to actually deliver a webhook#183
zaridan merged 2 commits into
mainfrom
feat/ht-dev-webhook-delivery

Conversation

@zaridan

@zaridan zaridan commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

🟡 NEEDS YOUR DECISION

Dev-tooling only — no engine behaviour changes. Gates green locally (typecheck clean, biome clean, 1762 tests pass). No bot has reviewed yet; this PR opens before review by protocol.

The bug this fixes

scripts/dev-api.ts wired the webhook store and queue but ran neither of the passes that move a delivery out of the engine. Events accumulated in event_outbox and stopped there.

The effect: registering a webhook endpoint through the dev API returned 201 and looked completely healthy, while the harness was structurally incapable of ever sending one. Nothing failed — nothing happened. This was found by trying to run a module against it end to end, not by reading the code.

What's added

  • src/dev/webhook-worker.ts — runs both passes on a timer. The outbox drain fans each committed event out to its matching endpoints; the queue drain signs and POSTs them. One pass at a time, interval measured from the end of a pass so a slow delivery cannot stack ticks, and a pass that throws is reported without killing the loop.
  • Shared instances. The API and the worker now use one endpoint store and one queue, so a webhook registered through the API is one the worker can actually see. They were separate before, which would have been a second silent failure.
  • src/dev/inject-inbound.ts + POST /__dev/inbound — pushes a synthetic message through the real ingest pipeline, so a local run can produce a conversation and the conversation.message_received event that follows. dev-inbound-email.ts fakes the provider interface for tests, but nothing in the harness was pumping it.
  • src/dev/dev-blob-store.ts — in-memory BlobStore, so ingest runs without Supabase Storage credentials.
  • Shutdown stops the worker before closing the database.

Verified against the running harness

Not just unit-tested. Booted the dev API, registered an endpoint, injected a message, and the endpoint received:

POST /api/hook
x-helpthread-event: conversation.message_received
x-helpthread-signature: t=1785708642, v1=f58b9a5e…
{"eventId":"e38a9918…","type":"conversation.message_received",
 "conversationId":"5ceeb710…","data":{"reopened":false,"threadId":"c774c8ea…"}}

Worker logged dispatched 1, delivered 1, failed 0.

Decision provenance

Decision — in plain words Source
Make the local dev harness able to deliver webhooks Follows from the end-to-end verification of the first module, which cannot run without it
The dev API gains a POST /__dev/inbound route that fakes an incoming email ⚠️ INFERRED — namespaced under /__dev/ and handled before the API bridge, so it is visibly not part of the engine's API surface. It exists only in scripts/dev-api.ts, which never ships
The dev harness delivers webhooks every second by default ⚠️ INFERRED — brisk enough that a local run feels immediate; overridable

Note on the SSRF guard

src/webhooks/ssrf.ts refuses loopback and RFC 1918 with no escape hatch, so a locally-running module cannot receive deliveries from a locally-running engine without a public hostname in between. That is correct behaviour and this PR does not touch it — but it is worth knowing before anyone else tries a fully-local module run and concludes the harness is broken.

Summary by CodeRabbit

  • New Features

    • Added development-only inbound message injection through a dedicated API endpoint.
    • Inbound messages now use the standard ingestion flow, including threading support.
    • Added automatic webhook delivery processing in the development environment.
    • Added development blob storage for handling message attachments and related content.
  • Bug Fixes

    • Improved webhook processing reliability with graceful shutdown, retry continuation, and idempotent delivery handling.
  • Tests

    • Added coverage for webhook dispatch, repeated processing, shutdown behavior, and error recovery.

`scripts/dev-api.ts` wired the webhook store and queue but ran neither of
the passes that move a delivery out of the engine. Events accumulated in
`event_outbox` and stopped there: registering an endpoint through the API
appeared to work, and nothing was ever sent. The harness looked healthy
while being structurally incapable of firing a webhook — the kind of gap
only an end-to-end run finds.

- `src/dev/webhook-worker.ts` runs both passes on a timer: the outbox
  drain fans each committed event to its matching endpoints, then the
  queue drain signs and POSTs them. One pass at a time, interval measured
  from the end of a pass, and a pass that throws is reported without
  stopping the loop.
- The API and the worker now share one endpoint store and one queue, so a
  webhook registered through the API is one the worker can see.
- `src/dev/inject-inbound.ts` plus a dev-only `POST /__dev/inbound` route
  push a synthetic message through the REAL ingest pipeline, so a local
  run can produce a conversation and the `conversation.message_received`
  event that follows it. `dev-inbound-email.ts` fakes the provider
  interface for tests; nothing was pumping it.
- `src/dev/dev-blob-store.ts` is an in-memory `BlobStore`, so ingest runs
  without Supabase Storage credentials.
- Shutdown stops the worker before closing the database.

Verified against the running harness, not just unit-tested: injected a
message, and the endpoint received a POST carrying
`X-Helpthread-Event: conversation.message_received`, a
`X-Helpthread-Signature` over the exact body, and the real conversation
and thread ids. Worker reported `dispatched 1, delivered 1, failed 0`.

Full suite: 1762 tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
helpthread Ready Ready Preview Aug 3, 2026 12:36am
helpthread-inbox Ready Ready Preview Aug 3, 2026 12:36am

Request Review

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dae2b52e-5eb0-4ffa-a29c-c1c32b00c0fe

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The development API now supports synthetic inbound messages, in-memory blob storage, shared webhook queueing, and background webhook delivery. The worker supports scheduled passes, error reporting, idempotent shutdown, and delivery tests.

Changes

Development inbound and webhook runtime

Layer / File(s) Summary
Inbound injection and development storage
src/dev/dev-blob-store.ts, src/dev/inject-inbound.ts
Adds process-local blob storage and RFC822 inbound-message injection through the real ingest pipeline.
Webhook worker lifecycle
src/dev/webhook-worker.ts, src/dev/webhook-worker.test.ts
Adds scheduled outbox and queue draining with delivery reporting and graceful shutdown. Tests cover dispatch, idempotency, shutdown, and recovery after errors.
Development API integration
scripts/dev-api.ts
Shares webhook stores and the queue, exposes POST /__dev/inbound, starts the worker, and stops it during shutdown.

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

Sequence Diagram(s)

sequenceDiagram
  participant DevClient
  participant DevAPI
  participant IngestPipeline
  participant WebhookQueue
  participant WebhookWorker
  DevClient->>DevAPI: POST /__dev/inbound
  DevAPI->>IngestPipeline: injectInboundMessage
  IngestPipeline->>WebhookQueue: enqueue webhook delivery
  WebhookWorker->>WebhookQueue: drain queued deliveries
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary change: enabling the development harness to deliver webhooks.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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-dev-webhook-delivery

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Comment thread scripts/dev-api.ts
console.error('[dev-api] inbound injection failed', err)
res.statusCode = 500
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))

@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: 3

🧹 Nitpick comments (4)
src/dev/webhook-worker.test.ts (3)

107-133: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Assert the retry with a deterministic clock or a bounded poll.

The test sleeps 120 ms and then asserts on calls and errors. Passes 2 and onward each perform a real outbound delivery attempt, so pass timing depends on network behavior. Replace the fixed sleep with a poll that waits until calls > 1, so a loaded CI runner cannot fail the test.

♻️ Proposed change
-    await new Promise((resolve) => setTimeout(resolve, 120))
+    await vi.waitFor(() => {
+      expect(calls).toBeGreaterThan(1)
+    })

Add vi to the vitest import if you apply this.

🤖 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/dev/webhook-worker.test.ts` around lines 107 - 133, Replace the fixed 120
ms sleep in the “keeps looping after a pass throws” test with a bounded poll
that waits until calls exceeds 1, using the existing Vitest utilities and adding
vi to the vitest import if needed. Keep the errors length assertion and enforce
a timeout so the test remains deterministic without waiting on outbound delivery
timing.

97-105: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

This test does not exercise the in-flight wait.

stop() runs before the first timer fires, so no pass ever starts and inFlight is still the initial resolved promise. The test verifies idempotent stop() only, not the title's "leaving no pass in flight". Add a wait longer than intervalMs before stop(), or start a pass with runOnce() and stop concurrently, to cover the wait path.

💚 Proposed addition
     worker = startWebhookWorker(h, { intervalMs: 10 })
+    // Let at least one pass start, so `stop()` actually awaits in-flight work.
+    await new Promise((resolve) => setTimeout(resolve, 30))
     await worker.stop()
🤖 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/dev/webhook-worker.test.ts` around lines 97 - 105, Update the “stops
cleanly, leaving no pass in flight” test to ensure a webhook pass is active
before calling stop, either by waiting longer than the configured intervalMs or
by starting runOnce() concurrently with worker.stop(). Preserve the existing
second-stop assertion so the test continues covering idempotent shutdown as well
as the in-flight wait path.

70-85: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Avoid real outbound requests in webhook worker tests.

This test uses module.example.com, so delivery still performs a real DNS lookup. The route rejects 127.0.0.1 for the SSRF guard, so use the existing HTTP transport seam: expose requestImpl in startWebhookWorker(), wire createWebhookDeliveryHandler({ ..., send: { requestImpl?: ... } }), and pass a stub from this factory.

🤖 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/dev/webhook-worker.test.ts` around lines 70 - 85, Update
startWebhookWorker and its delivery-handler setup to accept an optional
requestImpl and pass it through createWebhookDeliveryHandler’s send
configuration. In the test’s harness/factory, provide a stub request
implementation so the queued webhook delivery avoids DNS and outbound network
access while preserving the existing dispatched and failed assertions.
src/dev/webhook-worker.ts (1)

58-79: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Type handlers to the queue drain contract and drop the cast.

PostgresQueue.drainOnce accepts handlers: Record<string, QueueMessageHandler<unknown>>, so the handler map can be typed by import as DrainDeps['handlers'] and passed directly. The broad Record<string, ...> cast only broadens the type instead of strengthening it.

♻️ Proposed refactor
-  const handlers = {
+  import type { DrainDeps } from '../providers/adapters/postgres-queue/index.js'
+  const handlers: DrainDeps['handlers'] = {
     [WEBHOOK_DELIVERY_TOPIC]: createWebhookDeliveryHandler({
       webhookEndpoints: deps.webhookEndpoints,
     }),
   }
@@
-    const delivery = await deps.queue.drainOnce({
-      handlers: handlers as Record<
-        string,
-        Parameters<PostgresQueue['drainOnce']>[0]['handlers'][string]
-      >,
-    })
+    const delivery = await deps.queue.drainOnce({ handlers })
🤖 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/dev/webhook-worker.ts` around lines 58 - 79, Type the handlers map in the
worker using the queue drain contract, such as DrainDeps['handlers'], and remove
the broad Record<string, ...> cast from the deps.queue.drainOnce call. Import
the contract type from the existing queue module and keep the current webhook
delivery handler mapping unchanged.
🤖 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 `@scripts/dev-api.ts`:
- Around line 217-218: Secure the /__dev/inbound handler by validating the
API_TOKEN bearer header before reading the request body and returning HTTP 401
when absent or invalid. In the same handler, replace the injectInboundMessage
argument cast with runtime validation that mailboxId, from, to, subject, and
text are present with the expected shape, returning HTTP 400 for invalid JSON
data.
- Around line 273-275: Add a re-entrancy flag around the shutdown function in
the SIGINT/SIGTERM handling flow, such as the function containing
webhookWorker.stop(), and return immediately when shutdown has already started.
Set the flag before awaiting cleanup so a second signal cannot call
server.close() again, while preserving the existing cleanup order and allowing
db.close() to complete.

In `@src/dev/inject-inbound.ts`:
- Around line 43-57: Update buildRawMessage to reject any CR or LF characters in
the header values options.from, options.to, options.subject, and
options.inReplyTo before interpolating them into headers. Preserve valid message
construction and the existing optional In-Reply-To/References behavior, but fail
clearly when any value contains newline characters.

---

Nitpick comments:
In `@src/dev/webhook-worker.test.ts`:
- Around line 107-133: Replace the fixed 120 ms sleep in the “keeps looping
after a pass throws” test with a bounded poll that waits until calls exceeds 1,
using the existing Vitest utilities and adding vi to the vitest import if
needed. Keep the errors length assertion and enforce a timeout so the test
remains deterministic without waiting on outbound delivery timing.
- Around line 97-105: Update the “stops cleanly, leaving no pass in flight” test
to ensure a webhook pass is active before calling stop, either by waiting longer
than the configured intervalMs or by starting runOnce() concurrently with
worker.stop(). Preserve the existing second-stop assertion so the test continues
covering idempotent shutdown as well as the in-flight wait path.
- Around line 70-85: Update startWebhookWorker and its delivery-handler setup to
accept an optional requestImpl and pass it through
createWebhookDeliveryHandler’s send configuration. In the test’s
harness/factory, provide a stub request implementation so the queued webhook
delivery avoids DNS and outbound network access while preserving the existing
dispatched and failed assertions.

In `@src/dev/webhook-worker.ts`:
- Around line 58-79: Type the handlers map in the worker using the queue drain
contract, such as DrainDeps['handlers'], and remove the broad Record<string,
...> cast from the deps.queue.drainOnce call. Import the contract type from the
existing queue module and keep the current webhook delivery handler mapping
unchanged.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dedf8335-3919-4839-8b9b-a6abf781d9c3

📥 Commits

Reviewing files that changed from the base of the PR and between 64448bb and a7a9439.

📒 Files selected for processing (5)
  • scripts/dev-api.ts
  • src/dev/dev-blob-store.ts
  • src/dev/inject-inbound.ts
  • src/dev/webhook-worker.test.ts
  • src/dev/webhook-worker.ts

Comment thread scripts/dev-api.ts
Comment on lines +217 to +218
if (req.url === '/__dev/inbound' && req.method === 'POST') {
void (async () => {

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

/__dev/inbound accepts a request with no caller check and no body check. The route is handled before apiBridge, so it inherits none of the API's input handling. Both findings share that root cause: the handler trusts the caller and trusts the parsed JSON.

  • scripts/dev-api.ts#L217-L218: require the API_TOKEN bearer header before reading the body, and return HTTP 401 when it is absent or wrong. Loopback binding does not stop a cross-origin text/plain form POST from a page in the operator's browser.
  • scripts/dev-api.ts#L220-L225: replace the as Parameters<typeof injectInboundMessage>[0] cast with a runtime shape check on mailboxId, from, to, subject, and text, and return HTTP 400 for a bad body.
📍 Affects 1 file
  • scripts/dev-api.ts#L217-L218 (this comment)
  • scripts/dev-api.ts#L220-L225
🤖 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 `@scripts/dev-api.ts` around lines 217 - 218, Secure the /__dev/inbound handler
by validating the API_TOKEN bearer header before reading the request body and
returning HTTP 401 when absent or invalid. In the same handler, replace the
injectInboundMessage argument cast with runtime validation that mailboxId, from,
to, subject, and text are present with the expected shape, returning HTTP 400
for invalid JSON data.

Comment thread scripts/dev-api.ts
Comment on lines +273 to +275
// Stop the delivery loop and let any pass in flight finish, so nothing
// is mid-query against a database that is about to close.
await webhookWorker.stop()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard shutdown against a second signal.

webhookWorker.stop() is idempotent, but shutdown is not. Both SIGINT and SIGTERM call it. On a second signal, server.close() runs against an already-closed server and rejects with ERR_SERVER_NOT_RUNNING. The handlers use void shutdown() with no catch, so that becomes an unhandled rejection during shutdown, and db.close() never runs. Add a re-entrancy flag.

🛡️ Proposed guard
+  let shuttingDown = false
   const shutdown = async (): Promise<void> => {
+    if (shuttingDown) return
+    shuttingDown = true
     console.log('\n[dev-api] shutting down...')
🤖 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 `@scripts/dev-api.ts` around lines 273 - 275, Add a re-entrancy flag around the
shutdown function in the SIGINT/SIGTERM handling flow, such as the function
containing webhookWorker.stop(), and return immediately when shutdown has
already started. Set the flag before awaiting cleanup so a second signal cannot
call server.close() again, while preserving the existing cleanup order and
allowing db.close() to complete.

Comment thread src/dev/inject-inbound.ts
Comment on lines +43 to +57
export function buildRawMessage(options: InjectInboundOptions, messageId: string): string {
const headers = [
`From: ${options.from}`,
`To: ${options.to}`,
`Subject: ${options.subject}`,
`Message-ID: <${messageId}>`,
`Date: ${new Date().toUTCString()}`,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=utf-8',
]
if (options.inReplyTo !== undefined) {
headers.push(`In-Reply-To: <${options.inReplyTo}>`)
headers.push(`References: <${options.inReplyTo}>`)
}
return `${headers.join('\r\n')}\r\n\r\n${options.text}\r\n`

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

Reject CR/LF in header values.

buildRawMessage interpolates from, to, subject, and inReplyTo into header lines without checking for CR or LF. The dev route at scripts/dev-api.ts passes unvalidated JSON into these fields. A value that contains \r\n injects extra headers, or terminates the header block early and corrupts the body. The harness listens on loopback only, so this is a correctness problem for local runs rather than a production exposure.

🛡️ Proposed guard
+function headerValue(name: string, value: string): string {
+  if (/[\r\n]/.test(value)) {
+    throw new Error(`inject-inbound: ${name} must not contain CR or LF`)
+  }
+  return value
+}
+
 export function buildRawMessage(options: InjectInboundOptions, messageId: string): string {
   const headers = [
-    `From: ${options.from}`,
-    `To: ${options.to}`,
-    `Subject: ${options.subject}`,
+    `From: ${headerValue('from', options.from)}`,
+    `To: ${headerValue('to', options.to)}`,
+    `Subject: ${headerValue('subject', options.subject)}`,
     `Message-ID: <${messageId}>`,
🤖 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/dev/inject-inbound.ts` around lines 43 - 57, Update buildRawMessage to
reject any CR or LF characters in the header values options.from, options.to,
options.subject, and options.inReplyTo before interpolating them into headers.
Preserve valid message construction and the existing optional
In-Reply-To/References behavior, but fail clearly when any value contains
newline characters.

Codex (in place of CodeRabbit) reviewed the harness change and found seven
real defects. All are fixed. Every sacred-invariant category came back
clean with evidence — the production import graph cannot reach src/dev/**,
vercel.json's rewrites never route /__dev/*, the dev route cannot shadow
an /api/v1 path, no changed path sends mail or touches draft state, and
the fixed dev secrets stay inside scripts/dev-api.ts.

Worker:
- runOnce() is now serialized with the scheduled loop and tracked by
  stop(). It was untracked, so an explicit pass could overlap a scheduled
  one, and stop() could resolve while it was still querying — with the
  harness closing the database immediately after. It also now refuses to
  start after stop().
- Observer failures can no longer kill the loop. An onError that threw
  left a rejected promise with no handler, which on Node's defaults takes
  the process down; an async onActivity rejection was dropped entirely
  rather than reported. Both now route through one guarded reporter.
- The handler map is typed against the queue's own DrainDeps instead of
  cast into it, so the payload narrowing is visible rather than silenced.

Injector:
- Header values carrying CR or LF are refused. A bare newline in a subject
  or address ends the header — two end the header block — so an unescaped
  one silently rewrote the message around it.
- providerMessageId is caller-suppliable. Regenerating it unconditionally
  meant a retried POST created a second conversation and a second webhook,
  and made dedup unreachable from the harness.

Route:
- POST /__dev/inbound now requires the same Bearer token every other route
  in the harness does. Loopback-only, so not a production exposure, but
  the one route that fabricates customer mail should not be the exception.

Tests: the four the reviewer called unfalsifiable are replaced. Delivery
now asserts an actual signed POST through an injected transport rather
than a counter that also passes when delivery dies at DNS. Added: stop
waits for an in-flight pass, no pass runs after stop, runOnce rejects
after stop, passes never overlap under a race, the loop survives a
throwing onError and a rejecting onActivity, and CRLF refusal per header.

10 worker tests, 6 injector tests. Full suite: 1776 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@zaridan

zaridan commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Review adjudication

Codex (adversarial, in place of CodeRabbit — not installed on this org): 7 findings — all 7 real, all 7 fixed. None wrong.

# Finding Fix
1 runOnce() was untracked: it could overlap a scheduled pass, and stop() could resolve while it was still querying — with the harness closing the database immediately after Serialized with the loop and tracked by stop(); refuses to start after stop()
2 A throwing onError left a rejected promise with no handler — an unhandled rejection, which on Node's defaults takes the process down. An async onActivity rejection was dropped entirely Both route through one guarded reporter; the loop survives either
3 providerMessageId regenerated every call, so a retried POST created a second conversation and a second webhook — and dedup was unreachable from the harness Caller-suppliable, defaulting to a fresh id
4 CRLF injection in RFC822 headers: a newline in a subject or address ends the header, and two end the header block Header values carrying CR or LF are refused
5 POST /__dev/inbound bypassed the harness's Bearer gate Requires the same token as every other route
6 The handlers cast silenced a real contravariance mismatch Typed against the queue's own DrainDeps; the payload narrowing is now visible
7 Four tests could pass while the property was broken Replaced — see below

The tests that couldn't fail

The delivery test asserted delivered + failed === 1, which is equally true when delivery dies at DNS, at the SSRF check, or at secret lookup — none of which prove the queue-drain half ran at all. It now injects a transport and asserts an actual POST: the URL, X-Helpthread-Event, a well-formed X-Helpthread-Signature, and the conversation id in the body.

Added, each capable of failing: stop() waits for an in-flight pass; no pass runs after stop(); runOnce() rejects after stop(); passes never overlap under a three-way race; the loop survives a throwing onError and a rejecting onActivity; CRLF refused per header field.

10 worker tests, 6 injector tests.

Sacred invariants — clean, with evidence

The reviewer was asked to break four things and could not:

  • Nothing new is reachable from production. api/index.ts imports only the production composition root; neither src/composition/root.ts nor src/api/** imports any new dev module. Every new import is confined to scripts/dev-api.ts.
  • /__dev/inbound cannot be served by a deployment. vercel.json rewrites only /api/** and /. The dev branch is an exact raw-URL comparison, so it cannot shadow any /api/v1 path — a query string alone defeats the match.
  • No mail is sent, no draft state changes. Injection drives the ingest pipeline and the worker POSTs webhooks; nothing in the diff creates, approves, discards, or sends a draft, and the harness's reply path still resolves to the console-only dev sender.
  • The fixed dev secrets stay dev-only. Neither constant leaves scripts/dev-api.ts; production builds its own from configured values.

Gates

typecheck clean, biome clean, 1776 tests passing.

@zaridan
zaridan merged commit 9c5ebf8 into main Aug 3, 2026
8 checks passed
@zaridan
zaridan deleted the feat/ht-dev-webhook-delivery branch August 3, 2026 17:33
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.

2 participants