Connected Apps & Signed Hooks

Tier 2: external services reached with plugin-bound API keys, scoped webhooks, and signed synchronous draft/gate/score hooks.

A connected app is an external HTTPS service bound to a plugin id. It runs out of process and never executes code inside Convex or Nuxt, so it installs instantly — no rebuild, no redeploy. In exchange, everything it says is treated as untrusted.

The record

FieldNotes
pluginIdThe bundled plugin this app is bound to. Its manifest is the capability ceiling
nameDisplay name
endpointUrlMust be absolute https, with a hostname and no embedded user:pass@ credentials
grantedCapabilitiesA de-duplicated subset of the bound plugin's declared capabilities. Requesting anything the manifest does not declare is rejected at registration
statusenableddisabledenabled, or revoked (terminal)
Shared secretAES-256-GCM sealed envelope; never stored, logged, or returned in plaintext after minting

Read paths return one projection that omits the sealed secret columns by construction, so a new query cannot accidentally surface the ciphertext.

Registration and secret exchange

Registration mints a 256-bit secret with the recognizable cah_ prefix and seals it under a key derived by HKDF-SHA256 from INSTANCE_SECRET with connected-app-specific salt and info labels — a connected-app secret can never be opened under another consumer's key context.

The plaintext secret is shown exactly once

register and rotateSecret return the plaintext to the caller and nowhere else. Store it in your app immediately; if it is lost, rotate.

testConnection probes the configured endpoint (an unsigned reachability check) so an operator can validate setup before granting anything. Disable, revoke, and delete are separate operations; revoke is terminal.

API scopes and plugin-bound keys

A connected app authenticates to the v1 HTTP API with an API key bound to its plugin (apiKeys.pluginId). The scope string doubles as the plugin-capability string, and the effective scope set is re-derived on every request:

effective scopes = key scopes ∩ plugin manifest capabilities ∩ operator grants ∩ (plugin flag enabled)

Disabling the plugin or revoking a grant therefore neutralizes the key immediately. Endpoint scopes (contacts:read, contacts:write, events:write, transactional:send, topics:write) are also valid on standalone operator keys. The Tier-2-only scopes (campaigns:read, mail:read, knowledge:read, webhooks:manage, plugin-storage:read, plugin-storage:write) have no standalone meaning and are rejected at mint on an unbound key. Keys are least-privilege by construction: creation requires an explicit, non-empty scope list, and a legacy row with no scopes column is deny-all.

Signed synchronous hooks

The hook protocol defines three pipeline decision points a connected app can answer:

HookWhat the app returnsFail direction
draft{ draft: string } — a proposed reply bodyOpen: falls back to the built-in default draft strategy
gateA restrict-only verdictClosed: falls back to a caution objection (human review)
score{ score: number } in 0, 1, optional reasonOpen: falls back to "no score"

A gate response is structurally incapable of approving: there is no accept value in the schema. A connected app can add caution or work; it can never approve, unblock, or force a send.

Protocol only: runtime adapter not shipped

The signed hook wire protocol is specified, but Owlat does not ship a Convex runtime adapter or pipeline call site for it. The draft, route-gate and scoring stages run core and Tier-1 contributions only, so registering a connected app does not cause Owlat to call it at a decision point. The live half of Tier 2 is plugin-bound API keys (scope intersection re-derived per request) and connected-app registration with its sealed shared secret. Everything below "Wire contract" describes the contract a future implementation must satisfy; do not expect inbound hook traffic today.

Wire contract

Protocol version v1. Request headers:

HeaderValue
x-owlat-hookdraft | gate | score
x-owlat-hook-versionv1
x-owlat-hook-appConnected-app id, so the receiver selects the right secret
x-owlat-hook-timestampUnix seconds, signed
x-owlat-hook-noncePer-request 128-bit base64url nonce, signed
x-owlat-hook-signaturev1=<hex hmac>

Both directions are HMAC-SHA256 over a newline-joined canonical string with a fixed field order and a direction-specific domain tag:

request:                          response:
  owlat.hook.request.v1             owlat.hook.response.v1
  <hookKind>                        <hookKind>
  <connectedAppId>                  <connectedAppId>
  <timestampSeconds>                <nonce>            ← echoes the REQUEST nonce
  <nonce>                           <timestampSeconds>
  <sha256Hex(bodyBytes)>            <sha256Hex(bodyBytes)>

The body is bound by its SHA-256, so tampering invalidates the signature. The direction tag means a request signature can never be replayed as a response signature. The request nonce is folded into the response signing string, so a captured response cannot be replayed against a different request. Verification is constant-time.

Your app must verify the request signature and enforce its own freshness window on the signed timestamp, then sign its response the same way, echoing the request nonce. Owlat rejects a response whose signed timestamp is outside a 30-second tolerance.

What Owlat enforces on every call

ControlValue
Deadline5 s, then the fetch is aborted
Request body cap64 KiB
Response body cap64 KiB (drained under the cap; over-cap fails closed)
Response freshness30 s tolerance
Transporthttps only, SSRF guard with a private/internal blocklist applied up front and at connect time; redirects refused
Accepted draft textInjection-scrubbed and clamped to 65 536 code points
Accepted reason textInjection-scrubbed and clamped to 300 code points
Circuit breaker5 consecutive failures per (app, kind) opens it; a 60 s cooldown then allows one half-open trial

The runtime resolves the app and circuit state first and short-circuits without opening the secret or making a network call for a missing, disabled, or revoked app, a hook kind the operator has not granted, or an open breaker. Responses are strictly shape-validated: an extra key, a wrong type, or an empty string is rejected and the declared fallback applies.

Failure taxonomy

Every resolution records one fixed code — never free text and never the app's own message:

request_too_large, blocked_ssrf, redirect_refused, timeout, network_error, bad_status, response_too_large, signature_missing, signature_mismatch, stale_response, invalid_json, invalid_response, app_not_found, app_disabled, app_revoked, capability_denied, circuit_open, secret_unavailable, output_rejected, unexpected_error.

Delivery logs

Each resolution writes a redacted, tenant-scoped delivery-log row alongside the outbound webhook logs: hook kind, whether a network call was attempted, whether the app's value or the fallback won, the fixed fallback reason, and the network duration.

The redaction is structural: there is no column for the payload, the returned draft/gate/score text, the shared secret, or either signature. A logged delivery therefore cannot be replayed from the log. The only replay is the pipeline re-invoking the hook, which signs a fresh timestamp and nonce and re-runs the full restrict-only envelope — so a replayed gate can still only add caution.

Reads are org-scoped and bounded (default 50 rows, maximum 200, filterable by app, kind, and source). Rows age out at the audit-log retention of 30 days.

Reference implementation

examples/plugins/slack-approvals is the maintained Tier-2 reference: a restrict-only hold gate plus automation notifications, with authenticated Slack callbacks, expiration, duplicate-vote and quorum modelling — and tests proving Slack cannot force an approval or bypass Owlat's core gates.