feat(api,web): webhook receiver and delivery pull queue - #7
Open
marcorivm wants to merge 11 commits into
Open
Conversation
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
force-pushed
the
open-edition/reconciled
branch
from
August 8, 2026 18:30
36aebad to
caaf574
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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
updateManyrepeats 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 itsWHEREagainst the committed row version, findsavailableAtin the future, and matches nothing — so noSELECT … FOR UPDATE SKIP LOCKEDand no long-lived connection. Correctness rests on exactly two things, both commented in place: the guard repeats the predicate, andclaimIdis fresh per claim.Leases instead of a reaper. A poller that dies mid-batch leaves rows that become claimable when the lease passes.
attemptswas 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.
pollPendingsleeps 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
P2002rather than pre-reading, which is what makes it correct against a provider retrying concurrently.retryable: falseon a nack terminates a delivery immediately. A routing blob the consumer cannot interpret will fail identically forever; the reason lands inlastErrorand 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
200, not403. 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.404, not a silent202. Enumeration isn't a real threat against a 128-bit path, whereas a mistyped URL answered202debugs for hours while the provider's delivery list stays green.recordAuditEvent, notwithAudit.withAuditalso 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.tshas nouse("*").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.tsenumerates every route and asserts 401 without credentials. Keep that test green.routingis 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
*.pg.test.tssuite enabled against real Postgres.prisma migrate diff --exit-code→No difference detected.claimPendingcalls → every delivery claimed exactly once, union complete, intersection empty. Plus lease expiry → reclaim → the first poller's ack returnsstale, andattemptswalking out of the predicate.202queued ·200duplicate (no second row) ·401+ a rejected row on a bad signature ·200handshake onping·404unknown · long-poll returning in 0.4s with work and holding exactlywaitwhen empty · ack →delivered, re-ack →409· replay after a template edit rendering with the new template.Notes for the reviewer
POST /v1/internal/webhooks/sweepbehind the existing shared-secret guard.GET /v1/hooks/pendinggets its own Next segment withmaxDuration = 60so the long poll can park past the platform default without touching any other/v1route.WebhookVerifier.subscription,WebhookEndpoint.subscriptionState— are in place and inert, so adding it is code-only), push destinations, and lease extension.docs/webhooks.mdcarries 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