Contribution-Referenz
Jeder Contribution-Bucket, den ein Plugin-Manifest deklarieren kann, seine Capability, sein Modulvertrag und die Host-Semantik darum herum.
Eine Contribution ist ein Daten-Deskriptor in contributes.<bucket>[]. Ihre ausführbare Hälfte liegt, sofern vorhanden, an genau einem bedingungsunabhängigen Paket-Export, den die Codegen prüft, ohne ihn auszuführen.
Für jeden Bucket gelten ausnahmslos zwei Regeln:
- Das Manifest muss die Capability des Buckets und ein explizites
flagdeklarieren. Beides wird zusammen mit der Betreiber-Grant und etwaigenrequiredEnvVarsunmittelbar vor der Ausführung von Plugin-Code erneut geprüft. - Beigetragene Arten sind als
plugin.<pluginId>.<localId>namensraumgebunden, sodass ein Plugin niemals eine Kern-Art oder die Art eines anderen Plugins überdecken oder mit ihr kollidieren kann.
Bucket-Übersicht
Durchgängig verdrahtet
Für diese Contributions löst heute ein produktiver Host-Pfad auf und führt sie aus.
| Bucket | Capability | Läuft in | Fehlerrichtung |
|---|---|---|---|
sendTransports | send:transport | Convex-Node-Action | Typisierter Fehlercode; der Host besitzt die Retries |
agentSteps | agent:step | Convex-Node-Action | Lässt den Inbox-Lebenszyklus fail-closed scheitern |
draftStrategies | draft:strategy | Convex-Node-Action | Fällt auf die eingebaute default-Strategie zurück |
sendGates | send:gate | Convex-Node-Action | Leitet die Antwort in die menschliche Prüfung |
automationSteps | automation:step | Convex-Node-Action | Schritt-Ergebnis failed, host-eigener Retry |
crons | scheduler:cron | Convex-Node-Action | Der Lauf wird übersprungen |
navItems | ui:navigation | Nuxt (nur Daten) | Der Eintrag wird verworfen |
settingsPanels | ui:settings | Nuxt (nur Daten) | Der Eintrag wird verworfen |
Deklariert und katalogisiert — aber noch nicht aufgerufen
Für diese vier existieren der Manifest-Typ, die Capability-Prüfung des Validators und die Codegen-Metadaten. Es wird weder ein Host-Dispatch noch ein Autorisierungspfad ausgeliefert, ihre Deklaration hat heute also keine Laufzeitwirkung. Sie stehen hier und nicht bei den reservierten Namen, weil ihre Manifest-Formen verfügbar sind; ausführbare Host-Adapter müssen jedoch zusammen mit einem konkreten Produzenten landen.
| Bucket | Capability | Würde laufen in | Was heute fehlt |
|---|---|---|---|
automationTriggers | automation:trigger | Convex-Mutation | Es existiert keine Host-Naht zum Auslösen |
automationConditions | automation:condition | Convex-Query | Es gibt keinen Plugin-Condition-Evaluator — conditions/index.ts wirft bei einer plugin.*-Art |
webhookEvents | webhooks:publish | Nur Daten | Der Validator für persistierte Events ist eine geschlossene, ausschließlich Kern-Events umfassende Union, und kein Publish-Pfad autorisiert ein Plugin-Event |
importProviders | imports:provide | Convex-Node-Action | Die Provider-Registry des Import-Walkers ist Kern-only, und integrationImports.provider kann keine Plugin-Art aufnehmen |
Vier Capabilities haben keinen Contribution-Bucket: llm:invoke, plugin-storage:read und plugin-storage:write vermittelt der Host, während worker:enqueue für einen nicht ausgelieferten Tier-3-Adapter reserviert ist. Die Storage-Quoten und das LLM-Budget stehen in der Capability-Referenz; das ruhende Worker-Protokoll ist unter Gesandboxte Jobs dokumentiert.
Send-Transporte
sendTransports: [{
id, label, module: { exportPath }, retryDelays: [/* ≤ 3 bounded delays */],
requiredEnvVars: ['PLUGIN_ACME_TOKEN'], optionalEnvVars?, // PLUGIN_-prefixed, no "__"
credentialFields?, // the form, joined to those
supportsCustomReturnPath?, messageIdSource?, deduplicatesOnIdempotencyKey?,
webhook?, domainIdentity?
}]
Die gespeicherte Provider-Art ist plugin.<pluginId>.<localId>. Die Codegen erzeugt einen isolate-sicheren Metadatenkatalog plus eine separate ausführbare 'use node'-Registry.
import type {
PluginSendAttempt,
PluginSendTransportConfig,
PluginSendTransportModule,
PluginSendTransportParams,
} from '@owlat/plugin-kit';
interface RelayExtras {
readonly endpoint: string;
}
/** One attempt only. Owlat owns retries, health, routing, and audit. */
export const transport: PluginSendTransportModule<RelayExtras> = {
parseExtras(input: unknown): RelayExtras {
if (typeof input !== 'object' || input === null) {
throw new TypeError('extras must be an object');
}
const endpoint = (input as { endpoint?: unknown }).endpoint;
if (typeof endpoint !== 'string' || !endpoint.startsWith('https://')) {
throw new TypeError('extras.endpoint must be an https URL');
}
return { endpoint };
},
async send(
params: PluginSendTransportParams,
extras: RelayExtras,
// THIS INSTANCE's credentials, keyed by the name the manifest declared.
// Never `process.env`: that reads the deployment-default instance's token
// whichever transport id the send was addressed to.
config: PluginSendTransportConfig
): Promise<PluginSendAttempt> {
const response = await fetch(extras.endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${config.env['PLUGIN_ACME_TOKEN'] ?? ''}`,
},
body: JSON.stringify({ to: params.to, from: params.from, subject: params.subject }),
});
if (response.status === 429) return { success: false, code: 'rate_limited' };
if (!response.ok) return { success: false, code: 'temporary_failure' };
return { success: true, id: response.headers.get('x-message-id') ?? '' };
},
};
parseExtras ist die einzige Grenze für unbekannte Eingaben und muss den ehrlichen Extras-Typ zurückgeben oder werfen. send führt genau einen Netzwerkversuch aus. Die Fehlercodes bilden das feste Vokabular rate_limited, temporary_failure, ambiguous_timeout, invalid_recipient, invalid_sender, authentication_failed, content_rejected, unknown.
Unmittelbar vor jedem Versuch prüft der Host in einer Mutation erneut die Singleton-Organisation, die Registrierung, das Flag, die Deklaration, die exakte Grant und das Vorhandensein der erforderlichen Umgebungsvariablen. Eine Verweigerung ruft nie Plugin-Code auf und wird als access_denied auditiert. Terminale Audit-Zeilen enthalten nur Systemattribution, Ergebnis und Versuchszähler — niemals Adressen, Inhalte, Provider-IDs oder rohe Fehler.
Konfiguration und benannte Instanzen
Deklarieren Sie die Deployment-Variablen, die Ihr Transport liest, in requiredEnvVars / optionalEnvVars. Der Host löst sie auf und übergibt send ein drittes Argument — { instanceKey, env } —, das ausschließlich diese Variablen trägt, adressiert unter dem von Ihnen deklarierten Namen. Lesen Sie Ihre Credentials von dort und nicht aus process.env: Die Umgebung benennt die Deployment-Standardinstanz, ganz gleich an welche Transport-ID der Versand gerichtet war.
Namen müssen das Präfix PLUGIN_ tragen und dürfen kein __ enthalten. Das Präfix ist der Namensraum, der verhindert, dass einem Manifest ein Credential übergeben wird, das nicht dem Plugin gehört; __ ist der Instanz-Trenner, ein Basisname mit __ würde also die Variable einer anderen Instanz aliasieren.
Erst das Deklarieren einer Konfiguration verschafft Ihrer Art zudem benannte Instanzen: plugin.<pluginId>.<localId>#eu, in SEND_TRANSPORT_INSTANCES gelistet, liest PLUGIN_ACME_TOKEN__EU und kommt bei send als PLUGIN_ACME_TOKEN mit instanceKey: 'eu' an. Eine fehlende erforderliche Variable lässt den Versuch scheitern, bevor Ihr Modul läuft. Ein Transport, der keine Konfiguration deklariert, liest weiterhin die deployment-weiten Variablen des Plugins und bekommt keine benannten Instanzen, weil ein Suffix keine davon erreicht.
Ihr Transport gilt als konfiguriert, wenn sowohl die flag.requiredEnvVars des Plugins als auch Ihre requiredEnvVars vollständig vorhanden sind — die Vereinigung, nicht das eine oder das andere. Ein Transport, dessen Token innerhalb eines Plugins gesetzt ist, das niemand aktiviert hat, ist nicht versandfähig, und ihn als bereit zu melden hieße, ihn auf eine Route zu setzen, die der Dispatch-Pfad anschließend verweigert. Nur Ihre eigenen Variablen tragen das Suffix __<INSTANCEKEY>; eine Flag-Variable ist ein deployment-weiter Schalter und wird für jede Instanz ohne Suffix gelesen.
Die beiden Listen dürfen sich nicht überschneiden. Eine Variable, die sowohl in flag.requiredEnvVars als auch in den requiredEnvVars/optionalEnvVars eines Transports genannt wird, lässt die Manifest-Validierung scheitern, weil nur einer der beiden Geltungsbereiche das Suffix trägt: Eine benannte Instanz würde allein anhand von PLUGIN_ACME_TOKEN__EU als konfiguriert bewertet, während die deployment-weite Variable, die das gesamte Plugin absichert, ungeprüft bliebe — und jeder Versand an diese Instanz würde vom Autorisierungspfad dann für immer verweigert. Benennen Sie beides getrennt — PLUGIN_ACME_ENABLED für das Paket, PLUGIN_ACME_TOKEN für den Transport.
optionalEnvVars darf nur zusammen mit mindestens einem requiredEnvVars-Eintrag auftreten. Ein Transport, dessen gesamte Konfiguration optional ist, hat kein Credential, das ein Deployment setzen muss; nichts könnte also entscheiden, ob eine seiner benannten Instanzen konfiguriert ist — das Manifest wird abgelehnt, statt ihm stillschweigend instances_unsupported zu verpassen.
Das Credentials-Formular
credentialFields beschreibt, wie ein Betreiber nach den obigen Variablen GEFRAGT wird — dieselben typisierten Deskriptoren, die auch der Katalogeintrag eines Kern-Providers trägt, im bereits bekannten settingsSchema-Vokabular:
credentialFields: [
{ kind: 'secret', key: 'token', label: 'Server token', required: true, envVar: 'PLUGIN_ACME_TOKEN' },
{ kind: 'string', key: 'stream', label: 'Message stream', envVar: 'PLUGIN_ACME_STREAM' },
]
Die Arten sind string, secret, number, boolean und select — die beiden Komposita des Katalogs (region-select, host-port) gehören uns, und ein Transport drückt dieselbe Konfiguration über deren Bestandteile aus. Die envVar jedes Feldes muss eine von diesem Transport deklarierte sein, passend zum required des Feldes selbst: required: true benennt ein Mitglied von requiredEnvVars, alles andere ein Mitglied von optionalEnvVars. Diese Verknüpfung verhindert, dass ein gerendertes Formular nach einer Variable fragt, die kein Versand liest, oder genau die weglässt, die den Transport absichert.
Deskriptoren sind AUSSCHLIESSLICH BESCHREIBEND — nichts hier entscheidet, was ein Versand liest, und noch rendert keine Oberfläche das Formular eines Plugins.
Fähigkeiten
| Feld | Werte | Fehlen bedeutet |
|---|---|---|
supportsCustomReturnPath | no | no. Der einzige Wert, den dieses Tier hat; das Feld existiert, damit Sie ihn ausschreiben können. Die Werte yes und probe des Kern-Katalogs behaupten beide, dass Owlats eigener Bounce-Prozessor Ihre Bounces zuordnen kann — dafür braucht es einen Envelope-Sender, dessen Local Part ein VERP-Token ist, das Owlat mit einem Deployment-Secret signiert; ein Schlüssel, den kein gebündeltes Modul erhält. Eine solche Deklaration würde die Bounce-Daten Ihres Arms als vergleichbar mit unseren einstufen, während die Bounces bei Ihrem Provider landen — deshalb wird das Manifest abgelehnt. Ihr Feedback-Pfad ist der webhook weiter unten. |
messageIdSource | provider, composed | provider. composed sagt aus, dass Sie die von Owlat erzeugte Message-ID zurückspiegeln. |
deduplicatesOnIdempotencyKey | true, false | false. true erfordert zusätzlich einen buildSystemMailExtras-Export, der den Key in Ihren Request trägt; andernfalls verweigert der Host die Registrierung des Transports, denn eine Behauptung ohne die Verdrahtung macht aus einer Doppelzustellung einen „sicheren“ Retry. |
Zwei Katalogwerte werden aus dem ABGELEITET, was die Contribution mitbringt, statt daneben deklariert zu werden, weil ein Boolean neben der beschriebenen Sache nur im Widerspruch zu ihr stehen könnte. hasProviderFeedback ist genau dann wahr, wenn die Contribution einen webhook trägt; domainVerification ist genau dann api, wenn sie eine domainIdentity trägt, und andernfalls none. Die übrigen Werte des Kern-Katalogs — acceptanceSemantics: 'accepted' und messageIdSource: 'idempotency-key' — stehen diesem Tier nicht zur Verfügung; jeder von ihnen schaltet Host-Maschinerie ein, die noch nicht allgemein ist, deshalb werden sie bereits beim Authoring abgelehnt, statt Ihre Sendungen falsch zu etikettieren.
Beide Extras-Builder sind optional, rein und synchron — kein I/O, keine Uhr, keine Umgebung. buildDispatchExtras erhält die Routing-Fakten eines gesteuerten Versands (Idempotency-Key, Nachrichtentyp, Zustelldomain, IP-Pool, Warm-up-Überlauf, Engagement-Score), und buildSystemMailExtras erhält den Idempotency-Key des Aufrufers, sofern es einen gab. Was Sie zurückgeben, läuft durch Ihr eigenes parseExtras, bevor send es sieht. Der Return-Path-Host, den Owlat für seinen eigenen Relay-Arm auflöst, gehört bewusst nicht dazu: Er ist gegen das veröffentlichte SPF dieses Relays autorisiert, ihn aus einem anderen Transport heraus zu stempeln, würde also bei jeder so gestempelten Nachricht den SPF auf der Bounce-Domain scheitern lassen.
Feedback-Webhook
Ein Transport, der von seinem Provider Bounces, Beschwerden, Zustellungen und Deferrals erhält, deklariert einen zweiten Modul-Export an DERSELBEN Contribution — seine Feedback-Hälfte:
webhook: {
module: { exportPath },
signature: <one of the two host-verified schemes below>,
storeRawPayload?: boolean // default false
}
// The default. A contract that spells no `scheme` is this one.
signature: {
scheme?: 'hmac-timestamp-body',
header, algorithm, encoding, secretEnvVar, // PLUGIN_-prefixed
replay: { timestampHeader, toleranceSeconds } // ≤ 900
}
// Svix — what Resend and many other ESP consoles sign with.
signature: {
scheme: 'svix',
secretEnvVar, // PLUGIN_-prefixed
toleranceSeconds // ≤ 900
}
Wählen Sie das Schema, mit dem die Konsole Ihres Providers tatsächlich signiert. Der Svix-Arm trägt nur diese zwei Felder, weil alles Übrige — die Header svix-id / svix-timestamp / svix-signature, HMAC-SHA256, base64, der signierte String `${id}.${timestamp}.${body}`, die Secret-Form whsec_ — zum Schema gehört und einmal im Host implementiert ist; ein Manifest, das sie ausschreiben könnte, könnte dem benannten Schema nur widersprechen. Die beiden übrigen Host-Schemata, aws-sns und mandrill-form, stehen diesem Tier nicht zur Verfügung: Das erste ist Host-Infrastruktur (ein Zertifikat, das Owlat abruft und cacht, gebunden an ein Topic, das dem Deployment gehört), das zweite eine Legacy-Anbieterform, die über die öffentliche URL des Deployments selbst signiert wird.
Alle laufen auf einer Route auf: POST /webhooks/plugin/<pluginId>. Weil die Route nach Plugin-ID adressiert ist, darf höchstens ein Transport pro Plugin einen Webhook deklarieren; ein zweiter lässt die Manifest-Validierung scheitern, ebenso ein Webhook ohne signature, eine signature, die ein Schema benennt, mit dem Owlat nicht verifiziert, oder eine signature des Standard-Arms ohne replay-Vorkehrungen.
Führen Sie secretEnvVar in flag.requiredEnvVars auf — der Manifest-Validator verlangt es. Ohne das Secret kann der Host nichts verifizieren und beantwortet jede Zustellung mit 503, und eine Serie von Nicht-2xx-Antworten ist genau das, was einen Provider Ihren Endpunkt deaktivieren lässt; die Variable in requiredEnvVars zu nennen, verwandelt diesen unsichtbaren Fehlschlag in ein Plugin, das ein Betreiber schlicht nicht aktivieren kann, bevor das Secret gesetzt ist.
Der Host verifiziert, das Plugin parst. Owlat liest das Secret aus secretEnvVar, verifiziert die Bytes unter dem von Ihnen benannten Schema — derselbe Codepfad, der Owlats eigene Kern-Provider verifiziert, ein Wort zu wählen bedeutet also nie, einen Verifier zu liefern —, weist einen Zeitstempel zurück, der weiter von jetzt entfernt ist als toleranceSeconds, wendet eine bereits akzeptierte Zustellung exakt kein weiteres Mal an (eine Wiederholung wird mit 200 { success: true, duplicate: true } beantwortet, weil die übliche Ursache eine verlorene Bestätigung und kein Angreifer ist und ein 4xx gegen Ihren Endpunkt zählen würde) und prüft Flag, Grant und Umgebung erneut, bevor die Events landen. Ein Plugin sieht das Secret nie und entscheidet nie, ob ein Request authentisch ist — der Endpunkt ist unauthentifiziert und aus dem Internet erreichbar, seine Stärke kann also keine Eigenschaft von Drittanbietercode sein.
import type {
PluginSendTransportWebhookModule,
PluginWebhookFeedbackEvent,
} from '@owlat/plugin-kit';
/** Parse ONLY: these bytes are already verified, fresh, and not a replay. */
export const webhook: PluginSendTransportWebhookModule = {
parseEvents(rawBody: string): readonly PluginWebhookFeedbackEvent[] {
const batch = JSON.parse(rawBody) as { readonly records?: readonly unknown[] };
return (batch.records ?? []).flatMap((record) => {
const { type, id, at } = record as { type?: string; id?: string; at?: number };
if (type !== 'HardBounce' || typeof id !== 'string' || typeof at !== 'number') return [];
return [{ kind: 'bounced', providerMessageId: id, at, bounceType: 'hard' } as const];
});
},
};
Das Vokabular ist delivered, bounced, complained und deferred — die Fakten, die der Versand-Lebenszyklus und die Messebene verarbeiten. Geben Sie [] zurück für einen Batch, der nichts enthält, worauf Owlat reagiert (ein Verifikations-Ping des Providers, Event-Arten, die wir ignorieren); ein Wurf wird mit 400 beantwortet, und der Provider stellt möglicherweise erneut zu. Jedes zurückgegebene Feld wird vom Host erneut validiert, und providerType wird aus der Registry gestempelt, sodass ein Batch sich nicht einem anderen Transport zuschreiben kann. Provider-Message-IDs in den Namensräumen, die Owlat für selbst erzeugte Nachrichten reserviert — pb- (persönliche Postbox-Mail) und rp-probe. (Return-Path-Capability-Probes) —, werden aus demselben Grund abgelehnt: Die ID entscheidet, in welche Spur das Event dispatcht wird.
Dimensionieren Sie Ihre Batches auf diese beiden Limits. Beide werden mit 413 beantwortet, ohne dass etwas angewendet wird, und ein Provider, der eine übergroße Zustellung erneut versucht, bekommt wieder 413, bis er aufgibt — das darin enthaltene Feedback ist also verloren und nicht bloß verzögert. Konfigurieren Sie den Provider so, dass er stückelt; Owlat teilt nicht, was es abgelehnt hat.
| Limit | Obergrenze |
|---|---|
Request-Body (PLUGIN_WEBHOOK_MAX_BODY_BYTES) | 1 048 576 Bytes UTF-8 |
Zurückgegebene Events pro Zustellung (PLUGIN_WEBHOOK_MAX_BATCH_EVENTS) | 5 000 |
Dieses Modul läuft im Convex-Isolate (es wird vom HTTP-Router importiert) und darf daher keine Node-Builtins importieren. Rohe Request-Bodies werden nur aufbewahrt, wenn Sie storeRawPayload: true setzen — und wenn Sie das tun, wird ein verifizierter Body vor dem Lauf Ihres parseEvents gespeichert, sodass Sie genau die Zustellungen als Bytes erhalten, deren Bytes Sie am dringendsten brauchen (jene, die Ihre Parse-Hälfte abgewiesen hat).
Identität der Versanddomain
Ein Transport, dessen Provider über die Versanddomain eines Kunden informiert und gefragt werden muss, ob er für sie signieren darf, deklariert einen dritten Modul-Export an derselben Contribution:
domainIdentity: { module: { exportPath } }
Ein einziges Feld, denn alles andere an einer Domain-Identität gehört dem Host. Sie zu deklarieren IST domainVerification: 'api' für diese Art, und sie registriert Ihren Transport zur Kompositionszeit in der Relay-Identitäts-Registry des Hosts: Von da an fragt Sie das Routing-Gate, ob eine From-Domain über Sie relayed werden darf, das Identitäts-Backfill provisioniert Domains, die ein Betreiber früher verbunden hat, und der Alignment-Preflight bittet Sie, Ihren DKIM/SPF-Arm zu beschreiben.
Zwei Aufrufe, und die Trennung ist die, die jede Identitäts-API zieht. registerDomain ist der SCHREIBVORGANG — die Identität beim Provider anlegen oder bestätigen, idempotent, weil der Host bei einer expliziten Reparatur durch den Betreiber neu registriert. checkDomain ist der LESEVORGANG, den der Host nach eigenem Zeitplan wiederholt (täglich, sobald verifiziert; stündlich, solange DNS aussteht), um den Nachweis frisch zu halten. Beiden wird die aufgelöste Konfiguration des Transports übergeben, und sie müssen Credentials von dort lesen statt aus process.env; und keiner darf langsam sein: Der Host ruft sie aus einer geplanten Action heraus auf, und ein hängender Aufruf ist ein nicht aufgefrischter Nachweis, der veraltet.
Drei Antworten, weil der Host jede davon anders verbucht. ok trägt Beobachtungen und ist das einzige Ergebnis, das BELEG ist — das einzige, das das Alter des Nachweises auffrischt. auth_failed sagt, dass der Provider das Credential dieses Deployments abgelehnt hat: terminal, bis ein Betreiber es korrigiert, entsprechend protokolliert, und es überschreibt die bereits gespeicherten SPF/DKIM-Verdikte nicht, denn ein falscher API-Key ist kein Beleg dafür, dass das DNS des Betreibers ungültig geworden wäre. unavailable sagt, dass der Provider nicht geantwortet hat: Beleg für nichts, es bewegt sich also nur der Retry. Ein Modul, das wirft, wird als unavailable gelesen — der Host kann einen Bug in Ihrem Code nicht von einer Störung unterscheiden, und die konservative Lesart ist jene, die weder ein Credential verurteilt noch einen Nachweis auffrischt.
import type {
PluginDomainIdentityResult,
PluginSendTransportDomainIdentityModule,
} from '@owlat/plugin-kit';
/** Observations ONLY: the host derives the status and owns the freshness bound. */
export const domainIdentity: PluginSendTransportDomainIdentityModule = {
// The credential comes from the resolved configuration, never from
// `process.env`: an environment read resolves the deployment-default instance
// whichever instance the host meant.
registerDomain: (domain, config) => askProvider('POST', domain, config.env['PLUGIN_ACME_TOKEN']),
checkDomain: (domain, config) => askProvider('GET', domain, config.env['PLUGIN_ACME_TOKEN']),
};
async function askProvider(
method: 'GET' | 'POST',
domain: string,
token: string | undefined
): Promise<PluginDomainIdentityResult> {
const response = await fetch(`https://api.example.net/domains/${domain}`, {
method,
headers: { Authorization: `Bearer ${token ?? ''}` },
});
// Three distinguishable answers, because the host writes each one differently:
// only `ok` refreshes the proof's age, and only `auth_failed` condemns a key.
if (response.status === 401 || response.status === 403) {
return { outcome: 'auth_failed', error: 'the provider rejected the token' };
}
if (!response.ok) return { outcome: 'unavailable', error: `HTTP ${response.status}` };
const body = (await response.json()) as {
readonly owned?: boolean;
readonly spf?: boolean;
readonly dkim?: boolean;
readonly selector?: string;
};
return {
outcome: 'ok',
state: {
isOwnershipVerified: body.owned === true,
spf: { isValid: body.spf === true },
dkim: { isValid: body.dkim === true },
dkimSelectors: body.selector ? [body.selector] : [],
spfMechanisms: ['include:spf.example.net'],
},
};
}
Sie melden Beobachtungen, der Host entscheidet. Es gibt kein status-Feld, das Sie zurückgeben könnten. Owlat leitet ihn aus Ihren drei Beobachtungen ab — Ownership bestätigt, SPF gültig, DKIM gültig und mindestens ein auflösbarer Selector —, damit „verifiziert“ auf jeder Relay-Stufe dasselbe bedeutet und ein Modul eine Domain nicht als verifiziert melden kann, während es uns mitteilt, dass ihr DKIM-Record ungültig ist. Die Frischegrenze ist eine Host-Konstante (PLUGIN_RELAY_PROOF_MAX_AGE_MS, sieben Tage) und kein Manifest-Feld: Sie ist das Einzige, was einen Nachweis für eine auf Ihrer Seite widerrufene Identität außer Kraft setzt, während unsere Zeile fortbesteht — ein deklarierbares Fenster wäre also eine deklarierbare Aufweichung davon. Wo die Identitätszeile liegt, was ein fehlgeschlagener Aufruf überschreiben darf und wann erneut gefragt wird, gehört ebenfalls dem Host.
dkimSelectors und spfMechanisms werden am STATE mitgeführt statt im Manifest deklariert, weil beide Provider-Formen real sind — ein gemeinsamer, kontoweiter Selector oder Tokens je Domain, die erst nach der Registrierung existieren. Sie sind das, was der Alignment-Preflight für Dual-Transport live auflöst. Die beiden leeren Listen bedeuten NICHT dasselbe, deshalb lohnt Genauigkeit: Keine dkimSelectors bedeutet „wir können die Signaturidentität dieser Domain nicht beschreiben“, und das ist ein HOLD für die Ramp und niemals ein geöffnetes Gate (die Domain erreicht verified nicht, und es wird kein Referenzarm für sie beschrieben). Keine spfMechanisms bedeutet „dieses Relay benötigt keine SPF-Autorisierung auf der From-Domain des Kunden“ — der Preflight führt Ihre Mechanismen mit denen der eigenen MTA zu einem erforderlichen Satz zusammen, keine beizutragen lässt Ihre Anforderung also schlicht entfallen, und die SPF-Prüfung kann auf einem Record bestehen, der Sie nicht nennt. Geben Sie sie zurück, wann immer Sie sie kennen; ein gemeinsames Include, das Sie nicht aus Ihrer API auslesen können, ist besser hart kodiert als weggelassen. Höchstens 8 je Liste (PLUGIN_DOMAIN_IDENTITY_MAX_DNS_FACTS), jeweils höchstens 255 Zeichen (PLUGIN_DOMAIN_IDENTITY_MAX_DNS_FACT_LENGTH), und alles darüber wird verworfen statt abgelehnt. Der error bei einem fehlgeschlagenen Ergebnis oder einem ungültigen Record ist freier Text des Providers, der ausschließlich für eine Betreiber-Logzeile aufbewahrt, bei 500 Zeichen abgeschnitten (PLUGIN_DOMAIN_IDENTITY_MAX_ERROR_LENGTH) und nie als Handlungsanweisung gerendert wird.
Wie die Webhook-Hälfte wird auch dieses Modul von Code auf dem Enqueue-Pfad importiert und darf keine Node-Builtins importieren; seine Aufrufe sind HTTP, und fetch steht zur Verfügung. Jeder Aufruf wird zuvor erneut autorisiert — Flag an, send:transport weiterhin gewährt, Konfiguration vorhanden — und als transport.domain_identity auditiert, weil er das Credential dieses Deployments bei Ihrem Provider unter dem Domainnamen eines Kunden einsetzt. Das Plugin abzuschalten stoppt ihn, sichtbar.
Agent-Schritte
agentSteps: [{ id, after, module: { exportPath }, lifecycleEdges: [] }]
after ist ein Kern-Schritt (security_scan, context_retrieval, classify, clarify, draft) oder ein anderer Plugin-Schritt. Die Codegen weist unbekannte oder terminale Anker, doppelte Arten, Einfügezyklen und Kanten außerhalb der ausschließlich einschränkenden Policy des Hosts zurück.
import type {
PluginAgentStepInput,
PluginAgentStepModule,
PluginAgentStepResult,
} from '@owlat/plugin-kit';
export const agentStep: PluginAgentStepModule = {
async execute(input: PluginAgentStepInput): Promise<PluginAgentStepResult> {
if (input.subject.toLowerCase().startsWith('[auto-reply]')) {
// Restrict-only: a step may request a DECLARED caution edge, but never
// choose the next step, approve, or send.
return { kind: 'caution', to: 'archived', reason: 'vendor auto-reply' };
}
return { kind: 'continue' };
},
};
Fünf der sechs eingebauten Schritte bilden auf drei host-eigene Platzierungen ab; der sechste, der terminale route-Schritt, hat keine Platzierung und kann nicht als Anker dienen:
| Platzierung | Anker | Kanten, die ein Nachfolger anfordern darf |
|---|---|---|
classification | Security-Scan, Context-Retrieval, Classify | archived, failed aus classifying |
before_draft | Clarify | archived, failed aus drafting (es ist noch nicht garantiert, dass ein Entwurf existiert) |
after_draft | Draft | archived, failed und die Review-Kante drafting → draft_ready |
Ein Plugin, das hinter ein anderes Plugin gekettet wird, erbt dessen Platzierung. Kein Plugin darf approved oder sent anfordern, den nächsten Schritt wählen, vor dem Security-Scan laufen oder den Kern-Legalitätsgraphen bearbeiten. Der Walker nimmt stets die ursprüngliche Kern-Fortsetzung wieder auf; ungültige Ausgaben oder eine Exception lassen den Lebenszyklus fail-closed scheitern.
Draft-Strategien
draftStrategies: [{ id, label, module: { exportPath }, timeoutMs /* ≤ 30 000 */ }]
Eine Strategie ersetzt ausschließlich die primäre Generierung. Die Auswahlreihenfolge ist Kontakt, dann Postfach, dann Klassifikation, dann die eingebaute default-Strategie. Das Modul erhält eine eingefrorene, begrenzte Projektion plus den attribuierten, budgetierten LLM-Service (der separat llm:invoke erfordert) und gibt { draftBody } zurück.
Owlat hält das Injection-Scanning des zusammengestellten Kontexts, den Qualitäts-Selbstcheck, die Review-Optionen, die Persistenz, das Routing, die Autonomie und den Versand außerhalb der Strategie. Verweigerung, Timeout, Fehlschlag, veraltete Auswahl oder fehlerhafte/übergroße/injection-artige Ausgaben fallen alle einmalig auf default zurück. Siehe ADR-0050.
Autonomy-Gates (sendGates)
sendGates: [{ id, label, module: { exportPath }, timeoutMs /* ≤ 30 000 */ }]
Plugin-Gates laufen nach jedem unveränderlichen Kern-Gate zur Route-Zeit, in der generierten Katalogreihenfolge, einmal an der Freigabegrenze zur Route-Zeit. Das Modul erhält eine eingefrorene, begrenzte Mail-Projektion und ein AbortSignal — keinen Host-Service, kein Credential, keinen Convex-Context.
Der Ergebnistyp ist strukturell nicht in der Lage, eine Freigabe auszudrücken: { outcome: 'no-objection' } oder { outcome: 'objection', reason }. Deaktivierte, widerrufene, veraltete, fehlende, abgelaufene, fehlgeschlagene oder fehlerhafte Gates leiten die Antwort konservativ in die menschliche Prüfung. Das Audit hält ausschließlich feste Operations-/Ergebnis-/Begründungscodes fest. Siehe ADR-0051.
Automationen
automationTriggers: [{ id, label, description, icon, module: { exportPath } }]
automationSteps: [{ id, label, description, icon, module: { exportPath } }]
automationConditions: [{ id, label, description, icon, module: { exportPath } }]
Jede Registry hat ihre eigene Capability, sodass eine Grant die eine ohne die anderen freischalten kann. Editor-Metadaten werden wortgleich in den generierten Katalog kopiert, damit der Automation-Builder eine Contribution rendern kann, ohne Plugin-Code zu importieren; das Frontend behandelt sie weiterhin als nicht vertrauenswürdigen Text.
import type {
PluginAutomationStepInput,
PluginAutomationStepModule,
PluginAutomationStepResult,
} from '@owlat/plugin-kit';
interface NotifyConfig {
readonly channel: string;
}
export const automationStep: PluginAutomationStepModule<NotifyConfig> = {
parseConfig(raw: unknown): NotifyConfig {
const channel = (raw as { channel?: unknown } | null)?.channel;
if (typeof channel !== 'string' || channel.length === 0) {
throw new TypeError('config.channel is required');
}
return { channel };
},
async execute(
input: PluginAutomationStepInput,
config: NotifyConfig
): Promise<PluginAutomationStepResult> {
if (!input.contactEmail.includes('@')) {
return { kind: 'failed', reason: 'contact has no address' };
}
await Promise.resolve(config.channel);
return { kind: 'completed' };
},
};
Der Step-Walker besitzt Retries, den idempotenten Claim, den Abbruch und den Circuit Breaker; der gehostete Runner besitzt genau einen autorisierten Versuch mit einer host-eigenen 30-Sekunden-Deadline. Ein Plugin-Schritt kann abschließen oder fehlschlagen — er kann niemals erzwingen, dass ein Lauf fortschreitet. Fehlerbegründungen werden beschnitten und von Steuerzeichen befreit, bevor sie errorMessage erreichen.
Trigger entscheiden lediglich, ob ein Auslösen eine Automation startet; das Auffächern übernimmt der Host. Die Konfiguration eines Plugin-Triggers reist auf einem { pluginConfig }-Arm und wird vor parseConfig ausgepackt, und die Ausgabe von buildTriggerData wird auf begrenzte primitive Keys beschnitten, bevor sie die Run-Zeile erreicht. Conditions sind vertraglich verpflichtet, synchron innerhalb einer Query auszuwerten.
Von den drei Automation-Registries wird nur automationSteps dispatcht. Es gibt weder eine Naht zum Auslösen von Plugin-Triggern noch einen Plugin-Condition-Evaluator: conditions/index.ts wirft bei einer plugin.*-Art, statt „trifft nicht zu“ zurückzugeben, und kein Condition-Art-Validator lässt zu, dass eine solche in einem Segmentfilter persistiert wird. Katalog- und Capability-Obergrenzen sind reserviert; ausführbare Host-Pfade werden nicht vor einem Produzenten ausgeliefert.
Webhook-Events
webhookEvents: [{ id, description, subscribable }]
Nur Daten — das Plugin liefert für das Event keinen ausführbaren Code aus. Kern-Events behalten flache Literale (email.sent, contact.created); Plugin-Events sind plugin.<pluginId>.<localId>. subscribable: false bedeutet, dass Kunden-Endpunkte nicht abonnieren können und das Event immer nur an ein einzelnes explizites Ziel zugestellt wird. Payload-Daten, die dem Host beim Emit übergeben werden, sind nicht vertrauenswürdig und werden vor der Zustellung beschnitten und bereinigt.
Der komponierte Event-Katalog existiert, aber es wird weder eine Publish- noch eine Autorisierungsnaht ausgeliefert: Die Validatoren für persistierte Webhook-Events sind handaufgezählte, geschlossene Unions, die an die Kern-Registry gebunden sind — eine plugin.*-Event-Art kann also nicht an einem Endpunkt-Abonnement gespeichert werden und wird nie zugestellt. Den Bucket zu deklarieren ist heute wirkungslos.
Import-Provider
importProviders: [{ id, label, module: { exportPath }, signature, attestSource? }]
signature ist erforderlich: { header, algorithm: 'hmac-sha256' | 'hmac-sha1', encoding: 'hex' | 'base64', secretEnvVar }. Der Host liest das Secret aus der Umgebungsvariable, berechnet den HMAC über den rohen Body neu und vergleicht in konstanter Zeit. Die Verifikation schlägt fail-closed fehl, wenn das Secret nicht gesetzt ist oder der Header fehlt, fehlerhaft ist oder nicht übereinstimmt — ein Plugin kann sich dem nicht entziehen.
Der Signaturvertrag signiert allein den rohen Body — kein Zeitstempel, keine Toleranz, keine Nonce —, sein Bestehen beweist also Herkunft, nicht Aktualität. Er sichert heute keinen HTTP-Endpunkt ab. Das Stück, das eine eingehende HTTP-Fläche verdrahtet, muss zuvor eine Replay-Abwehr darüberlegen, bevor irgendein Endpunkt plugin-stämmigen Traffic annimmt. Zum Vergleich die signierten Hooks von Tier 2, die sehr wohl einen Zeitstempel und eine Nonce signieren.
import type {
JsonObject,
PluginImportPageResult,
PluginImportProviderInput,
PluginImportProviderModule,
} from '@owlat/plugin-kit';
export const importProvider: PluginImportProviderModule = {
validateConfig(config: JsonObject) {
return typeof config['listId'] === 'string'
? ({ ok: true } as const)
: ({ ok: false, reason: 'listId is required' } as const);
},
async fetchPage(input: PluginImportProviderInput): Promise<PluginImportPageResult> {
// `cursor` is `''` on the first page; return `null` to end the walk.
const page = input.cursor === '' ? 1 : Number(input.cursor);
return {
rows: [{ email: `contact-${page}@example.com`, fields: { source: 'vendor' } }],
nextCursor: page >= 2 ? null : String(page + 1),
};
},
};
Die generierte Modul-Registry für Import-Provider existiert, aber es wird keine Naht zur Start-Autorisierung ausgeliefert: Der Import-Walker dispatcht über eine ausschließlich kern-basierte INTEGRATION_IMPORT_PROVIDERS-Map, und integrationImports.provider ist eine Union aus zwei Literalen, die plugin.<pluginId>.<localId> nicht aufnehmen kann. Ein Import-Lauf kann einen beigetragenen Provider heute daher nie erreichen.
Crons
crons: [{ id, label, module: { exportPath }, schedule: { intervalMinutes }, timeoutMs }]
Scheduling-Limits, durchgesetzt bei der Manifest-Validierung, in der Codegen und bei der Registrierung:
| Limit | Wert |
|---|---|
schedule.intervalMinutes | 15 … 40 320 (vier Wochen) |
timeoutMs | 1 000 … 300 000 (fünf Minuten) |
Ein Plugin kann Hintergrundarbeit hinzufügen, aber nie eine Hot Loop oder einen faktisch nie laufenden Cron. Der registrierte Convex-Cron-Name ist die namensraumgebundene Art, Registrierungen sind also eindeutig. Jede Ausführung erhält { signal, logger, llm } — keinen Convex-Context, keine Tenant-ID, kein Credential — und der Abbruch erfolgt kooperativ über signal.
Navigations- und Einstellungseinträge
navItems: [{ id, section, name, href, icon, order? }]
settingsPanels: [{ id, name, href, icon, order? }]
Beide sind reine Daten: ein beschrifteter Link auf einen internen Dashboard-Pfad. Das Label wird auf 64 UTF-16-Code-Units beschnitten (ein astrales Zeichen zählt doppelt, sodass der Manifest-Validator und die renderseitige Beschneidung sich über das Budget einig sind), wobei Steuer- und Bidi-Formatzeichen beim Ableiten des Eintrags entfernt werden — das ist Spoofing-Abwehr, damit ein Plugin kein Label zeichnen kann, das ein Kern-Label optisch imitiert; die XSS-Abwehr ist das HTML-Escaping in Vue. Der Eintrag hängt am Plugin-Flag und wird deterministisch nach jedem Kern-Eintrag einsortiert.
Die Registry dedupliziert nach Ziel-href, wobei die erste Registrierung gewinnt und der Kern immer zuerst registriert wird — genau das verhindert, dass ein Plugin ein Kern-Ziel überdeckt. Ein Nav-Eintrag, der auf eine unbekannte oder per Feature abgeschaltete Section zeigt, wird verworfen; ein Plugin kann keine neue Top-Level-Section anlegen.
Ein Plugin kann keine Seite ausliefern. Zur Laufzeit wird kein beliebiger Browser-Code geladen, und die Codegen erzeugt keine Nuxt-Routen — href muss also auf eine Route auflösen, die der Dashboard-Build bereits hat, sonst wird der Link gerendert und läuft dann in ein 404. Das Ziel, das jedes Plugin geschenkt bekommt, ist seine eigene schema-gerenderte Einstellungsseite unter /dashboard/admin/instance/plugins/<pluginId>, auf die beide Referenzmanifeste verlinken. Alles andere muss eine Kern-Route sein oder eine Route, die der Build des Betreibers selbst bereitstellt.
Settings-Schema
settingsSchema ist kein Contribution-Bucket — es ist ein deklaratives Formular auf oberster Ebene, das der Host rendert, validiert, persistiert und schwärzt, sodass ein Plugin keinen eigenen Client-Code braucht.
| Feldart | Zusätzliche Felder |
|---|---|
string | default?, maxLength? |
secret | envVar (erforderlich, PLUGIN_-Präfix) — nur Deklaration, siehe unten |
number | default?, min?, max? |
boolean | default? |
select | options: [{ value, label }], default? |
Alle Arten tragen key, label, description?, required?. Obergrenzen: 64 Felder, 64 Optionen je Select, 8 192 Zeichen je Textwert. __proto__, constructor und prototype werden als Keys abgelehnt.
Ein secret-Feld speichert nichts. Es benennt eine Deployment-Umgebungsvariable mit PLUGIN_-Präfix, und der Host meldet nur, ob diese Variable vorhanden ist; ein Schreibvorgang für einen Secret-Key wird rundweg abgelehnt. Owlat hält damit überhaupt keinen Plugin-Credential-Klartext — es gibt keine Zeile, die leaken, keinen Envelope, der rotiert werden, und keinen Schlüssel, der kompromittiert werden könnte. Nichts anderes wäre sicher zu bauen: Kein Host-Pfad übergibt einem Plugin jemals seine Einstellungen (Plugins erhalten ausschließlich host-vermittelte Services), ein persistiertes Credential wäre also Write-only-Storage. Nutzen Sie flag.requiredEnvVars, wenn das Plugin ohne die Variable überhaupt nicht laufen darf.
Reservierte Namen
PLUGIN_CONTRIBUTION_KINDS enthält außerdem lifecycleEffects, assistantTools, inboundAdapters, emailBlocks, commands, panels, widgets und taskCards. Diese Buckets sind im Manifest-Typ reserviert und werden vom Validator als opake Arrays akzeptiert, aber noch verarbeitet sie weder eine Codegen- noch eine Host-Naht — die entsprechenden Kern-Registries existieren und stehen ausschließlich Kern-Modulen offen. Einen davon zu deklarieren hat heute keine Laufzeitwirkung. Behandeln Sie sie als reservierte Namen, nicht als Erweiterungspunkte.
Eine Reservierung ist nur so lange kostenlos, wie sie etwas Reales benennt — sie kann also zurückgezogen werden. channelAdapters wurde es: Die bidirektionale Channel-Adapter-Schnittstelle, auf die er zeigte, ist verschwunden (ihre beiden faked Implementierungen gelöscht, ihre drei funktionierenden neben die einzige Action verschoben, die sie dispatchte), und die Nähte, die sie ersetzt haben — sendTransports für ausgehend und inboundAdapters für eingehend —, haben bereits eigene Buckets. Ein Manifest, das heute channelAdapters deklariert, wird mit unknown_field abgelehnt, genau wie ein Tippfehler. Nichts spricht dagegen, dass der Name zurückkehrt, sobald es wieder eine Channel-Naht gibt.