diff --git a/examples/plugins/schedule-pulse/README.md b/examples/plugins/schedule-pulse/README.md new file mode 100644 index 000000000..d37c2daf8 --- /dev/null +++ b/examples/plugins/schedule-pulse/README.md @@ -0,0 +1,48 @@ +# Schedule Pulse + +A real scheduled plugin, not a log line. Multica POSTs the plugin's own HTTPS +endpoint every five minutes. The handler records each unique `delivery_id` in +workspace storage **before** it answers, so a retry of the same planned wake +does not look like a second pulse. + +The issue panel reads that storage row. After install you should see the count +go up about once every five minutes, not on every HTTP attempt. + +## What it verifies + +| Host behavior | What this plugin does | +| --- | --- | +| Durable schedule trigger | Declares `triggers: ["schedule"]` and a five-minute cron | +| At-least-once delivery | Persists `delivery_id` first; a retry with the same id returns `{duplicate: true}` | +| Plugin actor | Writes as the installation through the callback token, into `storage:workspace` | +| Consent | `net:pulse.example.com` is the only outbound host; schedule is not a data scope | + +It does not create issues. That is a later Plugin Action API, not this trigger. + +## Running the handler + +```bash +MULTICA_SIGNING_SECRET=whsec_… node server/handler.mjs +``` + +The signing secret is shown once, next to the install token, when an admin +rotates the plugin's token in workspace settings. + +Point `transport.url` at this process and declare the matching `net:` scope. +Local development also needs: + +```bash +export MULTICA_PLUGIN_DEV_ORIGINS=https://localhost:8787 +``` + +Without that opt-in the host refuses a private address. + +## Why storage, not a log + +A log line cannot prove idempotency. The panel shows `count`, `delivery_id`, +`planned_at`, and `attempt`. If the host retries the same plan, `attempt` +changes and `count` does not. + +The callback token is revoked when this HTTP request returns. All storage +writes happen before the 200. Work that must continue after the response +should use the standing `mpi_` install token instead. diff --git a/examples/plugins/schedule-pulse/multica.plugin.json b/examples/plugins/schedule-pulse/multica.plugin.json new file mode 100644 index 000000000..9fb8c74b3 --- /dev/null +++ b/examples/plugins/schedule-pulse/multica.plugin.json @@ -0,0 +1,48 @@ +{ + "manifest_version": 1, + "key": "ai.multica.schedule-pulse", + "name": "Schedule Pulse", + "description": "A scheduled Hook that Multica wakes every five minutes. Each unique delivery is recorded once in workspace storage, including when the host retries.", + "version": "1.0.0", + "author": { + "name": "multica", + "url": "https://multica.ai" + }, + "scopes": [ + "storage:workspace", + "net:pulse.example.com" + ], + "contributes": { + "surfaces": [ + { + "key": "pulse", + "type": "issue_panel", + "name": "Schedule pulse", + "entry": "ui/main.js", + "platforms": [ + "web", + "desktop" + ] + } + ], + "hooks": [ + { + "key": "pulse", + "name": "Schedule pulse", + "description": "Records one workspace-storage pulse per planned delivery. Retries of the same delivery_id do not increment the count.", + "triggers": [ + "schedule" + ], + "schedule": { + "cron": "*/5 * * * *", + "timezone": "UTC" + }, + "transport": { + "type": "http", + "url": "https://pulse.example.com/hooks/pulse" + }, + "timeout_ms": 8000 + } + ] + } +} diff --git a/examples/plugins/schedule-pulse/server/handler.mjs b/examples/plugins/schedule-pulse/server/handler.mjs new file mode 100644 index 000000000..9208b598b --- /dev/null +++ b/examples/plugins/schedule-pulse/server/handler.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +/** + * Schedule Pulse handler — the plugin author's side of a durable scheduled Hook. + * + * Run: + * MULTICA_SIGNING_SECRET=whsec_... node handler.mjs + */ + +import { createServer as createHTTPServer } from "node:http"; +import { createServer as createHTTPSServer } from "node:https"; +import { createHmac, timingSafeEqual } from "node:crypto"; +import { readFileSync } from "node:fs"; + +const PORT = Number(process.env.PORT ?? 8787); +const SIGNING_SECRET = process.env.MULTICA_SIGNING_SECRET ?? ""; +const TOLERANCE_SECONDS = 5 * 60; +const PULSE_KEY = "last_pulse"; + +if (!SIGNING_SECRET) { + console.error("MULTICA_SIGNING_SECRET is required. Rotate the plugin token in Multica to obtain it."); + process.exit(1); +} + +const seenSignatures = new Map(); + +function rememberSignature(signature, now) { + for (const [seen, at] of seenSignatures) { + if (now - at > TOLERANCE_SECONDS * 1000) seenSignatures.delete(seen); + } + if (seenSignatures.has(signature)) return false; + seenSignatures.set(signature, now); + return true; +} + +function verify(rawBody, headers) { + const timestamp = headers["x-multica-timestamp"]; + const presented = String(headers["x-multica-signature"] ?? "").replace(/^v1=/, ""); + if (!timestamp || !presented) return "missing signature headers"; + + const drift = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); + if (!Number.isFinite(drift) || drift > TOLERANCE_SECONDS) return "timestamp outside the accepted window"; + + const secret = Buffer.from(SIGNING_SECRET.replace(/^whsec_/, ""), "hex"); + const expected = createHmac("sha256", secret) + .update(timestamp) + .update(".") + .update(rawBody) + .digest("hex"); + + const expectedBytes = Buffer.from(expected, "utf8"); + const presentedBytes = Buffer.from(presented, "utf8"); + if (expectedBytes.length !== presentedBytes.length) return "signature does not match"; + if (!timingSafeEqual(expectedBytes, presentedBytes)) return "signature does not match"; + if (!rememberSignature(presented, Date.now())) return "this request was already delivered"; + return null; +} + +async function callback(body, method, path, payload) { + const response = await fetch(`${body.callback_url}${path}`, { + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${body.callback_token}`, + }, + body: payload === undefined ? undefined : JSON.stringify(payload), + }); + const text = await response.text(); + if (response.status === 404) return null; + if (!response.ok) { + throw new Error(`Multica answered ${response.status}: ${text}`); + } + return text ? JSON.parse(text) : null; +} + +async function loadPulse(body) { + const stored = await callback(body, "GET", `/storage/workspace/${PULSE_KEY}`); + if (!stored?.value) return { count: 0, delivery_id: "" }; + try { + return JSON.parse(stored.value); + } catch { + return { count: 0, delivery_id: "" }; + } +} + +const tlsCert = process.env.MULTICA_HOOK_TLS_CERT; +const tlsKey = process.env.MULTICA_HOOK_TLS_KEY; +const createServer = tlsCert && tlsKey + ? (handler) => createHTTPSServer({ cert: readFileSync(tlsCert), key: readFileSync(tlsKey) }, handler) + : createHTTPServer; + +const server = createServer((request, response) => { + if (request.method !== "POST" || request.url !== "/hooks/pulse") { + response.writeHead(404).end(); + return; + } + + const chunks = []; + request.on("data", (chunk) => chunks.push(chunk)); + request.on("end", async () => { + const rawBody = Buffer.concat(chunks); + const failure = verify(rawBody, request.headers); + if (failure) { + console.warn(`rejected: ${failure}`); + response.writeHead(401, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: failure })); + return; + } + + const body = JSON.parse(rawBody.toString("utf8")); + if (body.hook_key !== "pulse" || body.trigger !== "schedule") { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ skipped: "not a scheduled pulse" })); + return; + } + if (!body.delivery_id) { + response.writeHead(400, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: "scheduled delivery is missing delivery_id" })); + return; + } + + try { + const previous = await loadPulse(body); + if (previous.delivery_id === body.delivery_id) { + console.log(`duplicate delivery ${body.delivery_id} attempt ${body.attempt}`); + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ duplicate: true, delivery_id: body.delivery_id, count: previous.count })); + return; + } + + const pulse = { + delivery_id: body.delivery_id, + count: (previous.count ?? 0) + 1, + last_planned_at: body.schedule?.planned_at ?? "", + last_occurred_at: body.occurred_at ?? "", + last_attempt: body.attempt ?? 1, + }; + await callback(body, "PUT", `/storage/workspace/${PULSE_KEY}`, { value: JSON.stringify(pulse) }); + console.log(`pulse ${pulse.count} delivery ${pulse.delivery_id} attempt ${pulse.last_attempt}`); + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ accepted: true, ...pulse })); + } catch (error) { + console.error(error); + response.writeHead(502, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: "pulse failed" })); + } + }); +}); + +server.listen(PORT, () => console.log(`schedule pulse listening on ${tlsCert ? "https" : "http"}://127.0.0.1:${PORT}`)); diff --git a/examples/plugins/schedule-pulse/ui/main.js b/examples/plugins/schedule-pulse/ui/main.js new file mode 100644 index 000000000..92e34f2d0 --- /dev/null +++ b/examples/plugins/schedule-pulse/ui/main.js @@ -0,0 +1,67 @@ +// Schedule Pulse — reads the workspace-storage row the scheduled Hook writes. +// +// Plain ES module, no build step. The surface has no credential; it talks to +// Multica only through the host bridge. + +const pending = new Map(); +const port = globalThis.__multicaPluginBridgePortV2; +let sequence = 0; + +if (!(port instanceof MessagePort)) throw new Error("Multica surface bridge is unavailable"); +delete globalThis.__multicaPluginBridgePortV2; +port.onmessage = (message) => { + const payload = message.data; + if (payload?.kind === "theme") return applyTheme(payload.theme); + const entry = pending.get(payload?.id); + if (!entry) return; + pending.delete(payload.id); + if (payload.ok) entry.resolve(payload.data); + else entry.reject(new Error(payload.error)); +}; +port.start(); +start(); + +function applyTheme(theme) { + for (const [name, value] of Object.entries(theme ?? {})) { + document.documentElement.style.setProperty(name, value); + } +} + +function call(method, path, body) { + const id = `r${++sequence}`; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + port.postMessage({ id, kind: "action", method, path, body }); + }); +} + +function resize() { + port.postMessage({ id: `resize${Date.now()}`, kind: "ui.resize", height: document.body.scrollHeight + 16 }); +} + +async function start() { + const root = document.getElementById("root"); + root.innerHTML = ` +
{hook.schedule?.cron ?? ""}
+ {hook.schedule?.timezone ?? ""}
+ {showNextRun && hook.schedule?.next_run_at ? (
+
+ {t(($) => $.plugins.schedule.next_run, {
+ time: formatScheduleTime(hook.schedule?.next_run_at, locale),
+ })}
+
+ ) : null}
+