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
| Field | Notes |
|---|---|
pluginId | The bundled plugin this app is bound to. Its manifest is the capability ceiling |
name | Display name |
endpointUrl | Must be absolute https, with a hostname and no embedded user:pass@ credentials |
grantedCapabilities | A de-duplicated subset of the bound plugin's declared capabilities. Requesting anything the manifest does not declare is rejected at registration |
status | enabled → disabled ⇄ enabled, or revoked (terminal) |
| Shared secret | AES-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.
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:
| Hook | What the app returns | Fail direction |
|---|---|---|
draft | { draft: string } — a proposed reply body | Open: falls back to the built-in default draft strategy |
gate | A restrict-only verdict | Closed: falls back to a caution objection (human review) |
score | { score: number } in 0, 1, optional reason | Open: 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.
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:
| Header | Value |
|---|---|
x-owlat-hook | draft | gate | score |
x-owlat-hook-version | v1 |
x-owlat-hook-app | Connected-app id, so the receiver selects the right secret |
x-owlat-hook-timestamp | Unix seconds, signed |
x-owlat-hook-nonce | Per-request 128-bit base64url nonce, signed |
x-owlat-hook-signature | v1=<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
| Control | Value |
|---|---|
| Deadline | 5 s, then the fetch is aborted |
| Request body cap | 64 KiB |
| Response body cap | 64 KiB (drained under the cap; over-cap fails closed) |
| Response freshness | 30 s tolerance |
| Transport | https only, SSRF guard with a private/internal blocklist applied up front and at connect time; redirects refused |
| Accepted draft text | Injection-scrubbed and clamped to 65 536 code points |
| Accepted reason text | Injection-scrubbed and clamped to 300 code points |
| Circuit breaker | 5 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.