1// Tree-to-tree gateway channel handler.
2//
3// Connects trees across lands without Canopy federation. Canopy is
4// infrastructure-level peering between land operators. Gateway-tree is
5// user-level connection between tree owners. You don't need your land
6// operator to peer with their land operator. You just need your tree
7// to talk to their tree.
8//
9// Fully async. Same pattern as every other channel type.
10//
11// Output: POST the message to the remote land's input endpoint. The
12// remote land responds 200 immediately. Connection closed. Done.
13// The remote land processes the message in the background through
14// processGatewayMessage. If the remote channel is input-output,
15// the remote AI's reply fires as a separate output POST back to
16// this land's input endpoint.
17//
18// Input: receive POSTed JSON from a remote tree. Respond 200 immediately.
19// Process through processGatewayMessage in the background. If this
20// channel is input-output, the reply fires through this channel's
21// output (a separate POST to the remote land's input endpoint).
22//
23// Two async one-way messages. Not one synchronous round trip. Each
24// direction is independent. Each responds 200 before processing.
25// If the remote land is slow, this land isn't hanging. If the remote
26// AI fails, a cascade result with status:failed appears in .flow.
27//
28// Setup: both lands need gateway-tree installed. Both configure a channel
29// pointing at the other. Land A's output URL is Land B's input endpoint.
30// Land B's output URL is Land A's input endpoint. Two channels. Two
31// directions. Fully async.
32//
33// The message arrives as rain on the receiving land. The reply arrives
34// as rain on the sending land. Both flow through .flow. Both get
35// filtered by perspective. Both get processed by the conversation loop.
36// The gateway doesn't know it's talking to another tree. It just sends
37// and receives messages through the same interface every other channel uses.
38
39import log from "../../seed/log.js";
40import crypto from "crypto";
41
42// ─────────────────────────────────────────────────────────────────────────
43// VALIDATION
44// ─────────────────────────────────────────────────────────────────────────
45
46function validateConfig(config, direction) {
47 const hasOutput = direction === "output" || direction === "input-output";
48 const hasInput = direction === "input" || direction === "input-output";
49
50 if (hasOutput) {
51 if (!config.remoteUrl || typeof config.remoteUrl !== "string") {
52 throw new Error(
53 "Tree output requires a remoteUrl: the remote land's gateway-tree input endpoint " +
54 "(e.g., https://otherland.example.com/api/v1/gateway/tree/<theirChannelId>)"
55 );
56 }
57 try {
58 const parsed = new URL(config.remoteUrl);
59 if (!parsed.protocol.startsWith("http")) {
60 throw new Error("remoteUrl must use http or https");
61 }
62 } catch (err) {
63 if (err.message.includes("http")) throw err;
64 throw new Error("remoteUrl is not a valid URL");
65 }
66
67 if (!config.remoteSecret || typeof config.remoteSecret !== "string") {
68 throw new Error(
69 "Tree output requires a remoteSecret: the webhook secret of the remote land's receiving channel"
70 );
71 }
72 }
73
74 if (hasInput) {
75 // Input channels generate their own webhook secret on creation.
76 // Share it with the remote tree owner. They configure it as their remoteSecret.
77 if (config.senderFilter && typeof config.senderFilter !== "string") {
78 throw new Error("senderFilter must be a string (land domain or username)");
79 }
80 }
81}
82
83function buildEncryptedConfig(config, direction) {
84 const hasInput = direction === "input" || direction === "input-output";
85 const hasOutput = direction === "output" || direction === "input-output";
86
87 const secrets = {};
88 const metadata = {};
89
90 if (hasOutput) {
91 secrets.remoteUrl = config.remoteUrl;
92 secrets.remoteSecret = config.remoteSecret;
93 }
94
95 if (hasInput) {
96 metadata.webhookSecret = crypto.randomBytes(32).toString("hex");
97 if (config.senderFilter) metadata.senderFilter = config.senderFilter;
98 }
99
100 let display = "tree";
101 if (config.remoteUrl) {
102 try { display = new URL(config.remoteUrl).hostname; } catch (err) { log.debug("GatewayTree", "Could not parse remoteUrl for display:", err.message); }
103 }
104 if (config.remoteName) display = config.remoteName;
105
106 return {
107 secrets,
108 metadata,
109 displayIdentifier: display,
110 };
111}
112
113// ─────────────────────────────────────────────────────────────────────────
114// SENDER (output: fire-and-forget POST to remote land)
115// ─────────────────────────────────────────────────────────────────────────
116
117async function send(secrets, metadata, notification) {
118 const url = secrets.remoteUrl;
119 const secret = secrets.remoteSecret;
120
121 if (!url || !secret) {
122 throw new Error("Tree channel not configured for output (missing remoteUrl or remoteSecret)");
123 }
124
125 const payload = {
126 secret,
127 senderLand: process.env.LAND_DOMAIN || null,
128 message: notification.content || "",
129 title: notification.title || null,
130 type: notification.type || "notification",
131 };
132
133 // Fire and forget. We only care that the remote land accepted the message (200).
134 // The remote land processes in the background. If the remote AI produces a reply,
135 // it comes back as a separate POST to our input endpoint. Not in this response.
136 const res = await fetch(url, {
137 method: "POST",
138 headers: { "Content-Type": "application/json" },
139 body: JSON.stringify(payload),
140 signal: AbortSignal.timeout(10000), // 10s. Just waiting for 200 acceptance, not AI processing.
141 });
142
143 if (!res.ok) {
144 const body = await res.text().catch(() => "");
145 throw new Error(`Remote land responded ${res.status}: ${body.slice(0, 200)}`);
146 }
147}
148
149// ─────────────────────────────────────────────────────────────────────────
150// INPUT LIFECYCLE
151// ─────────────────────────────────────────────────────────────────────────
152
153async function registerInput(channel, secrets) {
154 const webhookSecret = channel.config?.metadata?.webhookSecret;
155 log.info("GatewayTree",
156 `Tree input registered for channel ${channel._id}. ` +
157 `Endpoint: POST /api/v1/gateway/tree/${channel._id}. ` +
158 `Share the webhook secret with the remote tree owner: ${webhookSecret ? webhookSecret.slice(0, 12) + "..." : "none"}`,
159 );
160}
161
162async function unregisterInput(channel, secrets) {
163 log.verbose("GatewayTree", `Tree input unregistered for channel ${channel._id}`);
164}
165
166export default {
167 allowedDirections: ["input", "output", "input-output"],
168 validateConfig,
169 buildEncryptedConfig,
170 send,
171 registerInput,
172 unregisterInput,
173};
174
1import log from "../../seed/log.js";
2import handler from "./handler.js";
3import { getExtension } from "../loader.js";
4
5export async function init(core) {
6 const gateway = getExtension("gateway");
7 if (!gateway?.exports?.registerChannelType) {
8 throw new Error("gateway-tree requires the gateway extension to be loaded first");
9 }
10
11 gateway.exports.registerChannelType("tree", handler);
12 log.verbose("GatewayTree", "Registered tree-to-tree channel type");
13
14 const { default: router } = await import("./routes.js");
15
16 return { router };
17}
18
1export default {
2 name: "gateway-tree",
3 version: "1.0.1",
4 builtFor: "treeos-connect",
5 description:
6 "Tree-to-tree gateway channel. Connects a tree on this land to a tree on " +
7 "another land, without Canopy federation. Canopy is infrastructure-level " +
8 "peering between land operators. Gateway-tree is user-level connection " +
9 "between tree owners. You don't need your land operator to peer with their " +
10 "land operator. You just need your tree to talk to their tree. " +
11 "\n\n" +
12 "The channel handles auth, rate limiting, and energy through the existing " +
13 "gateway framework. Two users connect their trees without any admin involvement. " +
14 "Land A's tree writes a note. The gateway-tree channel formats it and POSTs it " +
15 "to Land B's receiver endpoint. Land B's gateway processes it through the normal " +
16 "conversation loop. The AI responds. The response flows back in the HTTP response. " +
17 "Land A receives the reply as a gateway result. " +
18 "\n\n" +
19 "Output: sends tree content to a remote tree as a gateway message. The local " +
20 "tree's cascade signals, notes, or AI outputs become input for the remote tree. " +
21 "Input: receives messages from remote trees. The remote tree's output arrives " +
22 "as a gateway message on this land. processGatewayMessage fires. The AI at the " +
23 "connected node reads it, generates a response, and the response is returned " +
24 "to the caller in the HTTP response body. " +
25 "\n\n" +
26 "Input-output: bidirectional. Both trees talk to each other. A research tree " +
27 "on Land A asks questions. A knowledge tree on Land B answers. The conversation " +
28 "history lives on both sides as notes and contributions. Two trees, two lands, " +
29 "one conversation. No admin involvement. No federation required. " +
30 "\n\n" +
31 "Auth: the channel stores an API key or share token for the remote land. " +
32 "The remote land's gateway-tree extension verifies it against the receiving " +
33 "channel's webhook secret. Energy budget of the local channel owner covers " +
34 "outbound calls. Energy budget of the remote channel owner covers AI processing. " +
35 "Each side pays for its own work.",
36
37 needs: {
38 extensions: ["gateway"],
39 },
40
41 optional: {},
42
43 provides: {
44 models: {},
45 routes: false,
46 tools: false,
47 jobs: false,
48 orchestrator: false,
49 energyActions: {},
50 sessionTypes: {},
51 env: [
52 { key: "TREE_GATEWAY_SECRET", required: false, secret: true, autoGenerate: true, description: "Shared secret for verifying inbound tree-to-tree gateway messages" },
53 ],
54 cli: [],
55 },
56};
57
1// Tree-to-tree gateway webhook receiver.
2//
3// No auth middleware. Remote trees call this directly with a shared secret.
4// Same async pattern as every other channel type.
5//
6// Respond 200 immediately. Process in background. If input-output, the AI's
7// reply fires as a separate outbound POST through the gateway dispatch pipeline
8// (the channel's output side sends it back to the remote land's input endpoint).
9//
10// The remote land doesn't wait. The message arrives as rain. The reply
11// arrives as rain on the other side. Two one-way messages. Fully async.
12//
13// Endpoint: POST /api/v1/gateway/tree/:channelId
14//
15// Request body (JSON):
16// {
17// secret: string, // must match the channel's webhookSecret
18// senderLand: string, // domain of the sending land (informational)
19// message: string, // the content to process
20// title: string | null, // optional title/subject
21// type: string, // notification type (informational)
22// }
23
24import log from "../../seed/log.js";
25import { sendOk, sendError, ERR } from "../../seed/protocol.js";
26import express from "express";
27
28const router = express.Router();
29
30router.post("/gateway/tree/:channelId", async (req, res) => {
31 const channelId = req.params.channelId;
32 const body = req.body;
33
34 // Validate payload structure before responding
35 if (!body || !body.message || typeof body.message !== "string") {
36 return sendError(res, 400, ERR.INVALID_INPUT, "Missing or invalid message field");
37 }
38 if (!body.secret || typeof body.secret !== "string") {
39 return sendError(res, 401, ERR.UNAUTHORIZED, "Missing secret");
40 }
41
42 // Load and validate channel synchronously (fast DB lookup)
43 let channel;
44 try {
45 const { getExtension } = await import("../loader.js");
46 const GatewayChannel = getExtension("gateway")?.exports?.GatewayChannel;
47 channel = await GatewayChannel.findById(channelId).lean();
48 } catch {
49 return sendError(res, 500, ERR.INTERNAL, "Failed to load channel");
50 }
51
52 if (!channel || !channel.enabled) {
53 return sendError(res, 404, ERR.NODE_NOT_FOUND, "Channel not found or disabled");
54 }
55 if (channel.type !== "tree") {
56 return sendError(res, 400, ERR.INVALID_INPUT, "Channel is not a tree type");
57 }
58 const hasInput = channel.direction === "input" || channel.direction === "input-output";
59 if (!hasInput) {
60 return sendError(res, 400, ERR.INVALID_INPUT, "Channel does not accept input");
61 }
62
63 // Verify webhook secret
64 const expectedSecret = channel.config?.metadata?.webhookSecret;
65 if (!expectedSecret || body.secret !== expectedSecret) {
66 return sendError(res, 401, ERR.UNAUTHORIZED, "Invalid secret");
67 }
68
69 // Respond 200 immediately. Processing happens in background.
70 sendOk(res, { received: true });
71
72 // ── Background processing ──────────────────────────────────────────
73 try {
74 // Optional sender filter
75 const senderFilter = channel.config?.metadata?.senderFilter;
76 if (senderFilter && body.senderLand) {
77 const filterLower = senderFilter.toLowerCase();
78 const senderLower = body.senderLand.toLowerCase();
79 if (senderLower !== filterLower && !senderLower.endsWith("." + filterLower)) {
80 log.verbose("GatewayTree", `Filtered out message from ${body.senderLand} (filter: ${senderFilter})`);
81 return;
82 }
83 }
84
85 const senderName = body.senderLand || "remote-tree";
86 const senderPlatformId = body.senderLand || "unknown";
87 let messageText = body.message.trim();
88
89 if (body.title) {
90 messageText = `[${body.title}] ${messageText}`;
91 }
92 if (!messageText) return;
93
94 log.verbose("GatewayTree",
95 `Tree message on channel ${channelId} from ${senderName}: "${messageText.slice(0, 80)}"`,
96 );
97
98 // Process via gateway core. Same pipeline as Telegram, Discord, etc.
99 const { getExtension } = await import("../loader.js");
100 const gateway = getExtension("gateway");
101 if (!gateway?.exports?.processGatewayMessage) {
102 log.error("GatewayTree", "Gateway core not loaded");
103 return;
104 }
105
106 const result = await gateway.exports.processGatewayMessage(channelId, {
107 senderName,
108 senderPlatformId,
109 messageText,
110 });
111
112 // If input-output and there's a reply, dispatch it back through the
113 // gateway output pipeline. The channel's output side (send()) POSTs
114 // the reply to the remote land's input endpoint as a separate message.
115 if (result.reply && channel.direction === "input-output" && channel.rootId) {
116 try {
117 await gateway.exports.dispatchNotifications(channel.rootId, [{
118 type: "tree-reply",
119 title: null,
120 content: result.reply,
121 }]);
122 } catch (err) {
123 log.warn("GatewayTree", `Failed to dispatch reply for channel ${channelId}: ${err.message}`);
124 }
125 }
126 } catch (err) {
127 log.error("GatewayTree",
128 `Tree webhook processing error for channel ${channelId}:`,
129 err.message,
130 );
131 }
132});
133
134export default router;
135
Loading comments...