/*
RFC-0179: the dynamic-dispatch execution seam. The shared, token-free consumer never runs a
destination adapter itself — it dispatches each event into the originating tenant Worker, which runs
its gogol-adapter destinations with the CLIENT's own secrets and returns what routed/failed. This
keeps destination tokens and any datastore inside the isolated tenant while the delivery backbone is
shared. Pure contracts + two thin helpers (tenant-side executor, consumer-side dispatcher).
- Do not read or pass destination tokens through the shared consumer — only the tenant holds them.
- Do not persist event payloads — the dispatch is in-flight only (RFC-0177).
- RFC-0179: initial implementation.
*/
import type { IntegrationEvent, IntegrationSecrets } from "./port.ts";
import { routeEventToReady, type RouteResult } from "./index.ts";
/** Internal route the shared consumer dispatches into on the tenant (RFC-0179). */
export const DISPATCH_ROUTE = "/internal/integration-route" as const;
/** Envelope the shared consumer sends into a tenant's internal route. */
export interface DispatchExecuteRequest {
siteId: string;
event: IntegrationEvent;
}
/** What the tenant reports back. `routed`/`failed` are `${kind}:${vendor}` ids. */
export interface DispatchExecuteResult {
routed: string[];
failed: string[];
skipped: string[];
}
/**
* Tenant-side handler body for `POST /internal/integration-route` (RFC-0179).
* Runs the tenant's ready gogol-adapter destinations with the CLIENT's own
* secrets and an optional dedup set. The shared consumer never calls adapters
* directly — it calls this, so tokens stay inside the tenant.
*/
export async function executeDispatch(
request: DispatchExecuteRequest,
secrets: IntegrationSecrets,
seen?: { has(key: string): boolean | Promise; add(key: string): unknown },
): Promise {
const result: RouteResult = await routeEventToReady(request.event, secrets, seen);
return { routed: result.routed, failed: result.failed, skipped: result.skipped };
}
/**
* Minimal structural type for a Workers-for-Platforms dynamic-dispatch binding.
* `get(siteId)` returns the tenant Worker's fetcher; we never import the platform
* types (keeps @warpgogol/werkstatt-shared runtime-agnostic, mirroring QueueBinding/KvDedupStore).
*/
export interface DispatchNamespaceBinding {
get(siteId: string): { fetch(request: Request): Promise };
}
/** Outcome the consumer uses to decide ack vs retry for a queued message. */
export interface DispatchOutcome {
ok: boolean;
result?: DispatchExecuteResult;
/** Set when the dispatch itself failed (unreachable tenant / bad status). */
transportError?: string;
}
/**
* Consumer-side: dispatch one event into its originating tenant Worker over HTTP
* and interpret the reply (RFC-0179). The consumer authenticates with the shared
* inbound secret (the tenant already trusts that secret for its inbound route) and
* holds NO destination token. A non-2xx response or a reply where nothing routed
* but something failed is a transport failure → the caller should `retry()`.
*/
export async function dispatchToTenant(
dispatcher: DispatchNamespaceBinding,
request: DispatchExecuteRequest,
inboundSecret: string,
origin = "https://tenant.internal",
): Promise {
try {
const tenant = dispatcher.get(request.siteId);
const response = await tenant.fetch(
new Request(`${origin}${DISPATCH_ROUTE}`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-integration-secret": inboundSecret,
},
body: JSON.stringify(request),
}),
);
if (!response.ok) {
return { ok: false, transportError: `tenant responded ${response.status}` };
}
const result = (await response.json()) as DispatchExecuteResult;
// Nothing routed but something failed ⇒ retry the whole message (bounded → DLQ).
if (result.routed.length === 0 && result.failed.length > 0) {
return { ok: false, result, transportError: "all destinations failed" };
}
return { ok: true, result };
} catch (err) {
return { ok: false, transportError: (err as Error).message };
}
}