1// Web push (webhook) channel type handler.
2// Registered with gateway core during init. Output only.
3
4import log from "../../seed/log.js";
5
6// ─────────────────────────────────────────────────────────────────────────
7// VALIDATION
8// ─────────────────────────────────────────────────────────────────────────
9
10function validateConfig(config, direction) {
11 if (!config.subscription || typeof config.subscription !== "object") {
12 throw new Error("Web push channel requires a subscription object");
13 }
14 if (
15 !config.subscription.endpoint ||
16 typeof config.subscription.endpoint !== "string"
17 ) {
18 throw new Error("Web push subscription must have an endpoint");
19 }
20}
21
22function buildEncryptedConfig(config, direction) {
23 return {
24 secrets: { subscription: config.subscription },
25 metadata: {},
26 displayIdentifier: config.displayIdentifier || null,
27 };
28}
29
30// ─────────────────────────────────────────────────────────────────────────
31// SENDER
32// ─────────────────────────────────────────────────────────────────────────
33
34async function send(secrets, metadata, notification) {
35 const { subscription } = secrets;
36
37 let webpush;
38 try {
39 webpush = await import("web-push");
40 webpush = webpush.default || webpush;
41 } catch {
42 throw new Error("web-push package not installed");
43 }
44
45 const vapidPublic = process.env.VAPID_PUBLIC_KEY;
46 const vapidPrivate = process.env.VAPID_PRIVATE_KEY;
47 const vapidEmail = process.env.VAPID_EMAIL;
48
49 if (!vapidPublic || !vapidPrivate || !vapidEmail) {
50 throw new Error("VAPID keys not configured");
51 }
52
53 const email = vapidEmail.startsWith("mailto:") ? vapidEmail : "mailto:" + vapidEmail;
54 webpush.setVapidDetails(email, vapidPublic, vapidPrivate);
55
56 const payload = JSON.stringify({
57 title: notification.title,
58 body: notification.content,
59 type: notification.type,
60 });
61
62 try {
63 await webpush.sendNotification(subscription, payload);
64 } catch (err) {
65 if (err.statusCode === 410) {
66 // Subscription expired, disable the channel
67 try {
68 const { getExtension } = await import("../loader.js");
69 const GatewayChannel = getExtension("gateway")?.exports?.GatewayChannel;
70 await GatewayChannel.findByIdAndUpdate(notification._channelId, {
71 $set: { enabled: false, lastError: "Push subscription expired (410 Gone)" },
72 });
73 } catch (err2) { log.warn("GatewayWebhook", "failed to disable expired channel:", err2.message); }
74 throw new Error("Push subscription expired");
75 }
76 throw err;
77 }
78}
79
80// ─────────────────────────────────────────────────────────────────────────
81// EXPORT HANDLER
82// ─────────────────────────────────────────────────────────────────────────
83
84export default {
85 allowedDirections: ["output"],
86 validateConfig,
87 buildEncryptedConfig,
88 send,
89};
90
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-webhook requires the gateway extension to be loaded first");
9 }
10
11 gateway.exports.registerChannelType("webapp", handler);
12 log.verbose("GatewayWebhook", "Registered webapp (webhook) channel type");
13
14 return {};
15}
16
1export default {
2 name: "gateway-webhook",
3 version: "1.0.1",
4 builtFor: "treeos-connect",
5 description:
6 "Web push channel type for the gateway extension. Registers the webapp channel type at boot, " +
7 "enabling trees to push notifications to web browsers using the Web Push protocol (RFC 8030). " +
8 "This is output-only: browsers receive notifications from the tree, but cannot send messages " +
9 "back through this channel. For bidirectional browser communication, use the WebSocket " +
10 "connection or a different channel type.\n\n" +
11 "Configuration requires a Push API subscription object from the browser (containing endpoint, " +
12 "keys.p256dh, and keys.auth), which is stored encrypted in the channel's secrets. VAPID keys " +
13 "(public, private, and contact email) are set in environment variables and used for all web " +
14 "push channels on the land. The extension dynamically imports the web-push npm package at send " +
15 "time, so the package must be installed but the extension boots without it.\n\n" +
16 "When a notification fires, the handler serializes the title, body, and type into a JSON " +
17 "payload and delivers it through the web-push library, which handles VAPID signing, payload " +
18 "encryption, and the HTTP/2 push to the browser's push service endpoint. Expired subscriptions " +
19 "(HTTP 410 Gone) are handled gracefully: the channel is automatically disabled and the error " +
20 "is recorded, preventing repeated delivery attempts to a dead subscription. This means " +
21 "channels self-heal when a user clears their browser data or revokes notification permissions.",
22
23 needs: {
24 models: ["Node", "User"],
25 extensions: ["gateway"],
26 },
27
28 optional: {},
29
30 provides: {
31 models: {},
32 routes: false,
33 tools: false,
34 jobs: false,
35 orchestrator: false,
36 energyActions: {},
37 sessionTypes: {},
38 env: [
39 { key: "VAPID_PUBLIC_KEY", required: false, description: "VAPID public key for web push" },
40 { key: "VAPID_PRIVATE_KEY", required: false, secret: true, description: "VAPID private key for web push" },
41 { key: "VAPID_EMAIL", required: false, description: "VAPID contact email for web push" },
42 ],
43 cli: [],
44
45 hooks: {
46 fires: [],
47 listens: [],
48 },
49 },
50};
51
Loading comments...