Skip to content

feat(api,web): webhook receiver and delivery pull queue - #7

Open
marcorivm wants to merge 11 commits into
open-edition/reconciledfrom
feat/webhooks
Open

feat(api,web): webhook receiver and delivery pull queue#7
marcorivm wants to merge 11 commits into
open-edition/reconciledfrom
feat/webhooks

Conversation

@marcorivm

Copy link
Copy Markdown
Member

External systems (GitHub, alerting, generic senders) can now trigger agents through OneCLI.

A provider POSTs to a public per-endpoint URL; OneCLI verifies the signature, stores the delivery, renders a text template, and queues it. The agent runtime long-polls to drain the queue and acks each delivery.

GitHub ──▶ POST /v1/hooks/<publicId>
              verify → store → render → queue
                          ▲
  agent runtime ──────────┘   GET  /v1/hooks/pending   (outbound, held ~25s)
                              POST /v1/hooks/ack

Why pull

The machine running the agent needs no public port, no tunnel, and no inbound firewall rule. It also makes retries free: an unacked delivery becomes claimable again when its lease lapses, so a consumer that dies mid-dispatch loses nothing.

This supersedes an earlier design that put the whole surface — verification, templating, dedup, retry, replay, and a second admin UI — in a companion app beside the agent. That needed a public port on the agent's VM (colliding with exe.dev's one-public-port limit), a second database, and it only ever served one runtime. Putting the receiver here reuses auth, projects, audit, encryption, the dashboard, and an existing public HTTPS origin — and makes it usable by any runtime.

The interesting parts

The claim is race-free without a transaction. Candidates are selected, a guarded updateMany repeats the same availability predicate, then the claimed set is read back by a per-claim uuid. Under READ COMMITTED the loser of a race re-evaluates its WHERE against the committed row version, finds availableAt in the future, and matches nothing — so no SELECT … FOR UPDATE SKIP LOCKED and no long-lived connection. Correctness rests on exactly two things, both commented in place: the guard repeats the predicate, and claimId is fresh per claim.

Leases instead of a reaper. A poller that dies mid-batch leaves rows that become claimable when the lease passes. attempts was already incremented at claim time, so a consumer crashlooping on one delivery walks up to the cap and falls out of the predicate permanently.

The wait holds nothing. pollPending sleeps between queries, never inside one — no claim, no transaction, no pool connection. A SIGTERM mid-wait loses nothing and N parked pollers cost zero connections.

Verification runs over the raw bytes, before any parse — a re-serialized body changes the digest. Dedup catches P2002 rather than pre-reading, which is what makes it correct against a provider retrying concurrently.

retryable: false on a nack terminates a delivery immediately. A routing blob the consumer cannot interpret will fail identically forever; the reason lands in lastError and renders in the delivery dialog, so a misconfigured endpoint is diagnosable from the dashboard without SSH access to the box.

Decisions worth a second look

  • A muted endpoint answers 200, not 403. GitHub disables a webhook that fails continuously, so a local mute must stay invisible to the sender — otherwise a week-long mute silently breaks the integration and needs re-enabling on their side.
  • An unknown endpoint answers 404, not a silent 202. Enumeration isn't a real threat against a 128-bit path, whereas a mistyped URL answered 202 debugs for hours while the provider's delivery list stays green.
  • Secrets are re-readable, matching every other credential here (the project API key on the overview page, an agent's access token in its list row). A webhook secret gets re-pasted into a provider's config months later; show-once turns a copy slip into a forced rotation on their side.
  • Mutations use recordAuditEvent, not withAudit. withAudit also flushes the gateway's CONNECT cache — right for secrets and policy, waste for webhook config. Renaming a webhook shouldn't make every agent re-resolve its credentials. (This surfaced as a 500 in tests before it surfaced in production.)
  • routes/hooks.ts has no use("*"). Hono.route() flattens a sub-app's routes into the parent, so a sibling router's wildcard middleware would 401 the public ingest POST. The cost is that a route added later without inline middleware is silently public — hooks-admin.test.ts enumerates every route and asserts 401 without credentials. Keep that test green.
  • routing is opaque. OneCLI stores it, never parses it, and passes it through verbatim. Growing an opinion about its shape would couple the receiver to one consumer's semantics.

Verification

  • 1328 tests pass, 0 skipped, with every *.pg.test.ts suite enabled against real Postgres.
  • prisma migrate diff --exit-codeNo difference detected.
  • The pg proof that matters: 20 deliveries × 4 concurrent claimPending calls → every delivery claimed exactly once, union complete, intersection empty. Plus lease expiry → reclaim → the first poller's ack returns stale, and attempts walking out of the predicate.
  • Manual end-to-end against a running instance: 202 queued · 200 duplicate (no second row) · 401 + a rejected row on a bad signature · 200 handshake on ping · 404 unknown · long-poll returning in 0.4s with work and holding exactly wait when empty · ack → delivered, re-ack → 409 · replay after a template edit rendering with the new template.

Notes for the reviewer

  • The migration was generated by diffing the pre-change schema against the new one (Docker wasn't available at the time), then verified with the real drift check and applied cleanly. Plain tables and indexes only — no partial or expression indexes, which CI's check would reject.
  • Retention is opportunistic (throttled, off the ingest path) because there's no scheduler in this deployment, plus POST /v1/internal/webhooks/sweep behind the existing shared-secret guard.
  • GET /v1/hooks/pending gets its own Next segment with maxDuration = 60 so the long poll can park past the platform default without touching any other /v1 route.
  • Deliberately deferred: provider-native subscription (the seams — WebhookVerifier.subscription, WebhookEndpoint.subscriptionState — are in place and inert, so adding it is code-only), push destinations, and lease extension.
  • docs/webhooks.md carries the full consumer contract so a third-party runtime can be written from that page alone. The nanoclaw-side poller adapter is a separate repo and not in this PR.

https://claude.ai/code/session_012yM2sscojX6zzDUaVJXo6m

marcorivm added 11 commits July 29, 2026 16:54
Reconciliation Stage A. getCurrentPlan() reports enterprise; the OSS
policy validator wiring and now-empty policy-flags/policy-oss-locks
modules are removed (the coherence bridge was already dropped upstream);
lock affordances are edition-neutral (UnavailableBadge, informational
ProAppDialog); the OneCLI Cloud promo blocks and cloud_only error string
are removed. No agent-group code, no migration.
# Conflicts:
#	apps/web/src/lib/init/api.ts
Reconciliation Stage C. /v1/org/groups CRUD + replace-set membership
(org-membership validated on the global-FK userId), /groups as a single
user-groups view. Agent groups and the org-agent directory are dropped
(orphaned once agent groups are gone). No orphan-neutralization pass:
upstream's grants engine treats an FK-orphaned rule identity as inert, so
a group delete cannot widen a rule. +59 tests, no agent-group code, no
migration.
Reconciliation Stage D. /v1/projects (rename, access bindings replace-set,
safe delete with pinned cascade and last-owner/stranding guards) plus
/settings/project UI; /v1/org/role-mappings (CRUD, ordering, preview) with
monotonic raise-only apply re-wired into the group membership writers via
the applyRoleMappingsForGroup seam Stage C had stripped. Role changes
audit under MEMBER, config under ROLE_MAPPING. +136 tests, no agent-group
code, no migration. (Role-mappings management UI folds into Stage E.)
…44.0

Reconciliation Stage F. The OSS gateway now populates the org rule set and
the user/group PrincipalSet that upstream shipped but never filled: a new
loaders.rs adds the org published-rule loader and a principal CTE mirroring
the API's resolvePrincipalSet (users direct and via granted groups, active
members only; groups direct and inherited; fully org-fenced, agent-groups
dropped). Two-level evaluation mirrors upstream's own evaluator including
the hard-floor rule (a lone allow at one level cannot open the other
level's default block), so an org guardrail can't be bypassed by a project
allow. Fail-closed via upstream's anyhow refuse-CONNECT; our old
org_degraded/Fallback/kill-switch scaffolding is deleted. Empty org fails
OPEN. +21 tests, no agent-group, no signature changes to the call sites.
Reconciliation Stage G. condition_match.rs swaps its OSS no-op for the
real matcher (memchr body contains/equals/regex, size-limited regex cache,
case-insensitive header ops, 256 KiB cap); MatchInput/BodyCapture and the
body buffer re-thread onto upstream's rewritten forward.rs, and Stage F's
two-level evaluation carries conditions at both org and project scope for
free. Conditions are honored on connection, whole-app, secret, and network
targets (the two ignored-target bugs from the original Tier 3a are fixed
here too). Fail-closed by action at both scopes: any unevaluable condition
makes a Block match and every other rule fall through. Streaming stays the
default; buffered bodies reach upstream byte-identical. +22 tests, no
agent-group, no migration.
Reconciliation Stage H. The gateway enforces a connection's session_policy
as a monotone tightening on the final two-level decision — GitHub repos
(from the URL path) and Dropbox folders (from the buffered JSON body or the
Dropbox-API-Arg header). Reuses Stage G's body buffer via an OR-composed
needs_scope_body (no second buffer; the content host never buffers the file
body). provider is threaded through BOTH ResolvedRules construction sites
(the http-proxy path was silently dropping it). Fail-closed throughout:
dot-segment path traversal, path-less content RPCs, unknown providers with
a scope set, and unparseable requests all deny; uncovered providers deny
while scoped. +31 tests, no agent-group, no migration.
…ode sweep

Reconciliation Stage E (final feature stage). Mounts the OSS org policy
route via the eeRoutes seam (the write path and org-capable editor already
existed upstream — only the mount was missing, and removed-routes pointed
at a dangling path); adds the org policy page at /policy. The identity
picker is real again — people and user-groups only, no agent-groups — and
safe now that the gateway (Stages F/G/H) enforces those principals, so it
never writes rules the gateway ignores. Adds the role-mappings management
section to /groups (create/edit/reorder/delete, raise-only copy, live
blast-radius preview) that the backend had shipped without a UI. Sweeps the
unused SSO/SCIM/domain clients, hooks, types, and query keys. No
orphan-neutralization pass (inert under grants), no agent-group, no
migration.
Reconciliation Stage I (final). Per-secret monthly cost caps: the gateway
meters anthropic/openai token usage post-response (bounded stream tee, no
byte corruption, Accept-Encoding: identity so usage parses, prompt-cache
tokens priced), keeps a nano-dollar running total in the cache counter,
and enforces a pre-request 402 when over. Fail-OPEN on any metering or
read error (a cost control, not a security gate) while an over-budget org
is still blocked; the budget gate runs after the security decision so it
can never turn a Block into an allow. Org-scoped budget CRUD on the
eeRoutes seam + a Budgets tab. The reconciliation preserved every hook
site, so forward.rs needed no edits. +21 gateway / +12 api tests, no
agent-group, no migration.
External providers can now trigger agents through OneCLI. A provider POSTs to
a public per-endpoint URL; OneCLI verifies the signature, stores the delivery,
renders a text template, and queues it. The agent runtime long-polls to drain
the queue and acks each delivery.

The consumer pulls rather than being pushed to, so the machine running the
agent needs no public port, no tunnel, and no inbound firewall rule. It also
makes retries free: an unacked delivery becomes claimable again when its lease
lapses, so a consumer that dies mid-dispatch loses nothing.

This supersedes an earlier design that put the whole surface — verification,
templating, dedup, retry, replay, and a second admin UI — in a companion app
beside the agent, which needed a public port on the agent's VM and only ever
served one runtime.

Ingest (`POST /v1/hooks/:publicId`, the one public route):
- Verifier registry: GitHub HMAC-SHA256, shared token, or none. Verification
  runs over the raw bytes, before any parse — a re-serialized body changes the
  digest. Selecting "none" needs an explicit acknowledgement.
- Dedup by catching P2002 rather than pre-reading, which is what makes it
  correct against a provider retrying concurrently.
- A muted endpoint answers 200: GitHub disables a webhook that keeps failing,
  so a local mute must stay invisible to the sender.
- A failed signature is recorded without its payload, so an unverified caller
  cannot write arbitrary content into the log.

Queue (`GET /v1/hooks/pending`, `POST /v1/hooks/ack`):
- The claim is a guarded updateMany that repeats the availability predicate,
  read back by a per-claim uuid. Under READ COMMITTED the loser of a race
  re-evaluates its WHERE against the committed row and matches nothing, so no
  transaction or SKIP LOCKED is needed. Proven in the pg suite with four
  concurrent pollers over twenty deliveries.
- Leases instead of a reaper: an abandoned claim simply expires.
- The wait sleeps between queries, never inside one, so it holds no claim, no
  transaction, and no pool connection.
- `retryable: false` on a nack terminates immediately — a routing blob the
  consumer cannot interpret will fail identically forever.
- Authenticated by the agent access token, which is also the queue selector; a
  project key works but must name an agent explicitly.

The endpoint carries an opaque `routing` JSON that OneCLI never parses and
passes through verbatim, so a runtime keeps its own dispatch semantics without
OneCLI needing to know them.

Mutations audit through `recordAuditEvent`, not `withAudit`: the latter also
flushes the gateway's CONNECT cache, and renaming a webhook should not make
every agent in the project re-resolve its credentials.

`routes/hooks.ts` deliberately has no `use("*")` — Hono flattens a sub-app's
routes into the parent, so a sibling's wildcard middleware would 401 the public
ingest POST. A test enumerates every route and asserts 401 without credentials.

Claude-Session: https://claude.ai/code/session_012yM2sscojX6zzDUaVJXo6m
@marcorivm
marcorivm force-pushed the open-edition/reconciled branch from 36aebad to caaf574 Compare August 8, 2026 18:30
An error occurred while trying to automatically change base from open-edition/reconciled to open-edition/08-web-org-policy August 8, 2026 19:22
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