Connected Apps & signierte Hooks
Tier 2: externe Services, die über plugin-gebundene API-Keys, gescopte Webhooks und signierte synchrone Draft-/Gate-/Score-Hooks angesprochen werden.
Eine Connected App ist ein externer HTTPS-Service, der an eine Plugin-Id gebunden ist. Sie läuft außerhalb des Prozesses und führt niemals Code innerhalb von Convex oder Nuxt aus, weshalb sie sofort installiert ist — kein Rebuild, kein Redeploy. Im Gegenzug gilt alles, was sie sagt, als nicht vertrauenswürdig.
Der Datensatz
| Feld | Hinweise |
|---|---|
pluginId | Das gebündelte Plugin, an das diese App gebunden ist. Sein Manifest ist die Obergrenze der Capabilities |
name | Anzeigename |
endpointUrl | Muss absolutes https sein, mit Hostname und ohne eingebettete user:pass@-Zugangsdaten |
grantedCapabilities | Eine duplikatfreie Teilmenge der vom gebundenen Plugin deklarierten Capabilities. Die Anforderung von etwas, das das Manifest nicht deklariert, wird bei der Registrierung abgelehnt |
status | enabled → disabled ⇄ enabled oder revoked (terminal) |
| Shared Secret | Versiegelter AES-256-GCM-Umschlag; wird nach dem Erzeugen niemals im Klartext gespeichert, geloggt oder zurückgegeben |
Lesepfade liefern eine einzige Projektion, die die versiegelten Secret-Spalten konstruktionsbedingt auslässt, sodass eine neue Query den Ciphertext nicht versehentlich zutage fördern kann.
Registrierung und Secret-Austausch
Die Registrierung erzeugt ein 256-Bit-Secret mit dem wiedererkennbaren Präfix cah_ und versiegelt es unter einem Schlüssel, der per HKDF-SHA256 aus INSTANCE_SECRET mit connected-app-spezifischen Salt- und Info-Labels abgeleitet wird — ein Connected-App-Secret kann niemals unter dem Schlüsselkontext eines anderen Consumers geöffnet werden.
register und rotateSecret geben den Klartext an den Aufrufer zurück und sonst nirgendwohin. Speichern Sie ihn sofort in Ihrer App; geht er verloren, rotieren Sie ihn.
testConnection prüft den konfigurierten Endpoint (eine unsignierte Erreichbarkeitsprüfung), sodass ein Operator das Setup validieren kann, bevor er irgendetwas gewährt. Deaktivieren, Widerrufen und Löschen sind getrennte Operationen; das Widerrufen ist terminal.
API-Scopes und plugin-gebundene Keys
Eine Connected App authentifiziert sich gegenüber der v1-HTTP-API mit einem API-Key, der an ihr Plugin gebunden ist (apiKeys.pluginId). Der Scope-String ist zugleich der Plugin-Capability-String, und die effektive Scope-Menge wird bei jedem Request neu abgeleitet:
effective scopes = key scopes ∩ plugin manifest capabilities ∩ operator grants ∩ (plugin flag enabled)
Das Deaktivieren des Plugins oder das Widerrufen einer Gewährung neutralisiert den Key daher sofort. Endpoint-Scopes (contacts:read, contacts:write, events:write, transactional:send, topics:write) sind auch auf eigenständigen Operator-Keys gültig. Die reinen Tier-2-Scopes (campaigns:read, mail:read, knowledge:read, webhooks:manage, plugin-storage:read, plugin-storage:write) haben eigenständig keine Bedeutung und werden bei der Erzeugung auf einem ungebundenen Key abgelehnt. Keys sind konstruktionsbedingt least-privilege: Das Anlegen erfordert eine explizite, nicht leere Scope-Liste, und eine Legacy-Zeile ohne scopes-Spalte ist deny-all.
Signierte synchrone Hooks
Das Hook-Protokoll definiert drei Entscheidungspunkte in der Pipeline, die eine Connected App beantworten kann:
| Hook | Was die App zurückgibt | Fail-Richtung |
|---|---|---|
draft | { draft: string } — ein vorgeschlagener Antworttext | Offen: Rückfall auf die eingebaute default-Draft-Strategie |
gate | Ein ausschließlich restriktives Verdikt | Geschlossen: Rückfall auf einen Vorsichts-Einwand (menschliche Prüfung) |
score | { score: number } in 0, 1, optional reason | Offen: Rückfall auf "kein Score" |
Eine gate-Antwort ist strukturell außerstande zuzustimmen: Im Schema existiert kein Accept-Wert. Eine Connected App kann Vorsicht oder Arbeit hinzufügen; sie kann niemals genehmigen, entsperren oder einen Versand erzwingen.
Das Wire-Protokoll für signierte Hooks ist spezifiziert, aber Owlat liefert dafür weder einen Convex-Runtime-Adapter noch eine Aufrufstelle in der Pipeline aus. Die Draft-, Route-Gate- und Scoring-Stufen führen ausschließlich Core- und Tier-1-Contributions aus, sodass das Registrieren einer Connected App nicht dazu führt, dass Owlat sie an einem Entscheidungspunkt aufruft. Der aktive Teil von Tier 2 sind plugin-gebundene API-Keys (Scope-Schnittmenge pro Request neu abgeleitet) und die Registrierung von Connected Apps mit ihrem versiegelten Shared Secret. Alles unterhalb von "Wire-Contract" beschreibt den Vertrag, den eine künftige Implementierung erfüllen muss; erwarten Sie heute keinen eingehenden Hook-Traffic.
Wire-Contract
Protokollversion v1. Request-Header:
| Header | Wert |
|---|---|
x-owlat-hook | draft | gate | score |
x-owlat-hook-version | v1 |
x-owlat-hook-app | Connected-App-Id, damit der Empfänger das richtige Secret auswählt |
x-owlat-hook-timestamp | Unix-Sekunden, signiert |
x-owlat-hook-nonce | 128-Bit-Base64url-Nonce pro Request, signiert |
x-owlat-hook-signature | v1=<hex hmac> |
Beide Richtungen sind HMAC-SHA256 über einen mit Zeilenumbrüchen verbundenen kanonischen String mit fester Feldreihenfolge und einem richtungsspezifischen 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)>
Der Body ist über seinen SHA-256 gebunden, sodass Manipulation die Signatur ungültig macht. Das Richtungs-Tag bedeutet, dass eine Request-Signatur niemals als Response-Signatur wiedereingespielt werden kann. Die Request-Nonce fließt in den Signierstring der Response ein, sodass eine abgefangene Response nicht gegen einen anderen Request wiedereingespielt werden kann. Die Verifikation erfolgt in konstanter Zeit.
Ihre App muss die Request-Signatur verifizieren und ein eigenes Frische-Fenster auf dem signierten Zeitstempel durchsetzen, dann ihre Antwort auf dieselbe Weise signieren und dabei die Request-Nonce zurückspiegeln. Owlat weist eine Antwort ab, deren signierter Zeitstempel außerhalb einer Toleranz von 30 Sekunden liegt.
Was Owlat bei jedem Aufruf erzwingt
| Kontrolle | Wert |
|---|---|
| Deadline | 5 s, danach wird der Fetch abgebrochen |
| Obergrenze Request-Body | 64 KiB |
| Obergrenze Response-Body | 64 KiB (bis zur Obergrenze geleert; oberhalb der Grenze fail-closed) |
| Frische der Response | 30 s Toleranz |
| Transport | ausschließlich https, SSRF-Schutz mit einer Blockliste privater/interner Ziele — sowohl vorab als auch zum Verbindungszeitpunkt angewandt; Redirects werden verweigert |
| Akzeptierter Draft-Text | injektionsbereinigt und auf 65 536 Codepoints begrenzt |
| Akzeptierter Reason-Text | injektionsbereinigt und auf 300 Codepoints begrenzt |
| Circuit Breaker | 5 aufeinanderfolgende Fehlschläge pro (App, Kind) öffnen ihn; nach 60 s Cooldown ist ein halboffener Versuch erlaubt |
Die Runtime löst zuerst App und Circuit-Zustand auf und bricht kurz — ohne das Secret zu öffnen und ohne Netzwerkaufruf — ab bei einer fehlenden, deaktivierten oder widerrufenen App, einer Hook-Art, die der Operator nicht gewährt hat, oder einem offenen Breaker. Antworten werden strikt auf ihre Form validiert: ein zusätzlicher Key, ein falscher Typ oder ein leerer String wird abgelehnt, und der deklarierte Fallback greift.
Fehler-Taxonomie
Jede Auflösung protokolliert genau einen festen Code — niemals freien Text und niemals die Meldung der App selbst:
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.
Zustell-Logs
Jede Auflösung schreibt neben den ausgehenden Webhook-Logs eine redigierte, tenant-gescopte Zustell-Log-Zeile: Hook-Art, ob ein Netzwerkaufruf versucht wurde, ob der Wert der App oder der Fallback gewonnen hat, der feste Fallback-Grund und die Netzwerkdauer.
Die Redigierung ist strukturell: Es gibt keine Spalte für die Payload, den zurückgegebenen Draft-/Gate-/Score-Text, das Shared Secret oder eine der beiden Signaturen. Eine geloggte Zustellung kann daher nicht aus dem Log wiedereingespielt werden. Die einzige Wiederholung besteht darin, dass die Pipeline den Hook erneut aufruft — mit frisch signiertem Zeitstempel und frischer Nonce und einem erneuten Durchlauf des vollständigen, ausschließlich restriktiven Umschlags. Ein erneut ausgeführtes Gate kann also weiterhin nur Vorsicht hinzufügen.
Lesezugriffe sind org-gescopt und begrenzt (Standard 50 Zeilen, Maximum 200, filterbar nach App, Art und Quelle). Zeilen verfallen mit der Audit-Log-Aufbewahrung von 30 Tagen.
Referenzimplementierung
examples/plugins/slack-approvals ist die gepflegte Tier-2-Referenz: ein ausschließlich restriktives Hold-Gate plus Automations-Benachrichtigungen, mit authentifizierten Slack-Callbacks, Ablauf, Modellierung von Doppelabstimmungen und Quorum — samt Tests, die belegen, dass Slack weder eine Genehmigung erzwingen noch die Core-Gates von Owlat umgehen kann.