diff --git a/src/backend/.gitignore b/src/backend/.gitignore
index ed0ef7365..9c5ee7b7e 100644
--- a/src/backend/.gitignore
+++ b/src/backend/.gitignore
@@ -144,8 +144,11 @@ keys
# credentials
creds*
+# test-webhook persisted key
+tools/.test-webhook-config.json
+
# thumbnai-service
thumbnail-service
# init sql generated from ./run.sh
-init.sql
\ No newline at end of file
+init.sql
diff --git a/src/backend/src/modules/broadcast/BroadcastService.js b/src/backend/src/modules/broadcast/BroadcastService.js
index 44f9ec42e..ac0b2d03a 100644
--- a/src/backend/src/modules/broadcast/BroadcastService.js
+++ b/src/backend/src/modules/broadcast/BroadcastService.js
@@ -69,6 +69,12 @@ class BroadcastService extends BaseService {
const svc_event = this.services.get('event');
svc_event.on('outer.*', this.on_event.bind(this));
+
+ // Test event (logs a message to console if DEBUG is set in env)
+ svc_event.on('test', (key, data, _meta) => {
+ const { contents } = data;
+ console.log(`Test Message: ${contents}`);
+ });
}
async on_event (key, data, meta) {
@@ -94,7 +100,7 @@ class BroadcastService extends BaseService {
}).attach(app);
}
- handleWebhookRequest_ (req, res) {
+ async handleWebhookRequest_ (req, res) {
const rawBody = req.rawBody;
if ( rawBody === undefined || rawBody === null ) {
res.status(400).send({ error: { message: 'Missing or invalid body' } });
@@ -128,6 +134,8 @@ class BroadcastService extends BaseService {
return;
}
+ this.log.debug('received peerId', { value: peerId });
+
const peer = this.peersByKey_[peerId];
if ( !peer || !peer.webhook_secret ) {
res.status(403).send({ error: { message: 'Unknown peer or webhook not configured' } });
@@ -192,7 +200,10 @@ class BroadcastService extends BaseService {
const svc_event = this.services.get('event');
const metaOut = { ...meta, from_outside: true };
const context = Context.get(undefined, { allow_fallback: true });
- context.arun(async () => {
+ await context.arun(async () => {
+ this.log.debug('Emitting to the event service', {
+ key, data, metaOut,
+ });
await svc_event.emit(key, data, metaOut);
});
diff --git a/src/backend/tools/README.md b/src/backend/tools/README.md
index aadfb4781..cd45ab88e 100644
--- a/src/backend/tools/README.md
+++ b/src/backend/tools/README.md
@@ -1,5 +1,20 @@
# Backend Tools Directory
+## Manual Test for Broadcast Webhook Support
+
+`test-webhook.js` can be used for manual testing the `/broadcast/webhook` endpoint.
+It prints a one-off peer config (peer id and `webhook_secret`) for you to add to your instance’s broadcast config,
+then prompts for the instance base URL and sends an event with key `"test"`.
+
+**Usage** (from repo root):
+
+```bash
+node src/backend/tools/test-webhook.js
+```
+
+Add the printed peer to your config under `broadcast.peers`, restart the instance, then run the script and enter the instance URL
+(your Puter API URL, such as `http://api.puter.localhost:4100`) when prompted.
+
## Test Kernel
The **Test Kernel** is a drop-in replacement for Puter's main kernel. Instead of
diff --git a/src/backend/tools/test-webhook.js b/src/backend/tools/test-webhook.js
new file mode 100644
index 000000000..afd09af13
--- /dev/null
+++ b/src/backend/tools/test-webhook.js
@@ -0,0 +1,224 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const readline = require('readline');
+
+const CONFIG_PATH = path.join(__dirname, '.test-webhook-config.json');
+
+function randomHex (bytes) {
+ return crypto.randomBytes(bytes).toString('hex');
+}
+
+function loadConfig () {
+ try {
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
+ const data = JSON.parse(raw);
+ if ( data && typeof data.key === 'string' && typeof data.webhook_secret === 'string' ) {
+ const out = {
+ key: data.key,
+ webhook_secret: data.webhook_secret,
+ nonce: typeof data.nonce === 'number' ? data.nonce : 0,
+ };
+ if ( typeof data.instance_url === 'string' && data.instance_url.trim() !== '' ) {
+ out.instance_url = data.instance_url.trim().replace(/\/+$/, '');
+ }
+ return out;
+ }
+ } catch (e) {
+ const is_not_found = e.code === 'ENOENT';
+ if ( ! is_not_found ) {
+ console.error('Saved config exists but could not be read:', e);
+ }
+ }
+ return null;
+}
+
+/**
+ * Saves a dotfile beside the script so new configuration doesn't need to be
+ * re-entered into Puter every time this script is used.
+ * @param {*} peerId - The peer ID to save.
+ * @param {*} webhookSecret - The webhook secret to save.
+ * @param {*} nonce - The nonce to save.
+ * @param {*} instanceUrl - The instance URL to save.
+ */
+function saveConfig (peerId, webhookSecret, nonce, instanceUrl) {
+ const payload = {
+ key: peerId,
+ webhook_secret: webhookSecret,
+ nonce,
+ };
+ if ( typeof instanceUrl === 'string' && instanceUrl.trim() !== '' ) {
+ payload.instance_url = instanceUrl.trim().replace(/\/+$/, '');
+ }
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(payload, null, 2), 'utf8');
+}
+
+/**
+ * This wrapper around readline.question is used to promisify the interface
+ * and remove whitespace from the input.
+ *
+ * @param {*} rl
+ * @param {*} question
+ * @param {*} defaultAnswer
+ * @returns {Promise} - The trimmed answer.
+ */
+function ask (rl, question, defaultAnswer = '') {
+ const prompt = defaultAnswer ? `${question} [${defaultAnswer}]: ` : `${question} `;
+ return new Promise((resolve) => {
+ rl.question(prompt, (answer) => {
+ const trimmed = answer.trim();
+ resolve(trimmed !== '' ? trimmed : defaultAnswer);
+ });
+ });
+}
+
+async function main () {
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
+
+ let peerId;
+ let webhookSecret;
+ let nonce;
+ const existing = loadConfig();
+
+ if ( existing ) {
+ const useExisting = await ask(rl, 'Existing key found. Use it? (y/n)', 'y');
+ const noAnswers = ['n', 'no'];
+ if ( noAnswers.includes(useExisting.toLowerCase()) ) {
+ peerId = `test-webhook-${randomHex(8)}`;
+ webhookSecret = randomHex(32);
+ nonce = 0;
+ saveConfig(peerId, webhookSecret, nonce, existing.instance_url);
+ console.log('');
+ console.log('New key generated.');
+ console.log('');
+ console.log('Add the following peer to your Puter instance config so it can accept');
+ console.log('webhooks from this test script. In your config file (e.g. config.json),');
+ console.log('under the "broadcast" section, add a "peers" array (if missing) and');
+ console.log('include this entry:');
+ console.log('');
+ console.log(JSON.stringify({
+ key: peerId,
+ webhook_secret: webhookSecret,
+ }, null, 2));
+ console.log('');
+ console.log('Example config structure:');
+ console.log(' "broadcast": {');
+ console.log(' "peers": [');
+ console.log(' { "key": "", "webhook_secret": "" }');
+ console.log(' ]');
+ console.log(' }');
+ console.log('');
+ console.log('Restart your Puter instance after updating the config.');
+ console.log('');
+ } else {
+ peerId = existing.key;
+ webhookSecret = existing.webhook_secret;
+ nonce = existing.nonce;
+ console.log('');
+ console.log('Using existing key:', peerId);
+ console.log('');
+ }
+ } else {
+ peerId = `test-webhook-${randomHex(8)}`;
+ webhookSecret = randomHex(32);
+ nonce = 0;
+ saveConfig(peerId, webhookSecret, nonce, undefined);
+ console.log('');
+ console.log('Add the following peer to your Puter instance config so it can accept');
+ console.log('webhooks from this test script. In your config file (e.g. config.json),');
+ console.log('under the "broadcast" section, add a "peers" array (if missing) and');
+ console.log('include this entry:');
+ console.log('');
+ console.log(JSON.stringify({
+ key: peerId,
+ webhook_secret: webhookSecret,
+ }, null, 2));
+ console.log('');
+ console.log('Example config structure:');
+ console.log(' "broadcast": {');
+ console.log(' "peers": [');
+ console.log(' { "key": "", "webhook_secret": "" }');
+ console.log(' ]');
+ console.log(' }');
+ console.log('');
+ console.log('Restart your Puter instance after updating the config.');
+ console.log('');
+ }
+
+ const defaultUrl = existing && existing.instance_url ? existing.instance_url : '';
+ const baseUrl = await ask(rl, 'Instance base URL (e.g. http://api.puter.localhost:4100)', defaultUrl);
+ const url = baseUrl.trim().replace(/\/+$/, '');
+ if ( ! url ) {
+ console.error('Please provide a URL.');
+ rl.close();
+ process.exit(1);
+ }
+
+ const webhookUrl = `${url}/broadcast/webhook`;
+ const timestamp = Math.floor(Date.now() / 1000);
+ const body = {
+ key: 'test',
+ data: { contents: 'I am a test message from test-webhook.js' },
+ meta: {},
+ };
+ const rawBody = JSON.stringify(body);
+ const payloadToSign = `${timestamp}.${nonce}.${rawBody}`;
+ const signature = crypto.createHmac('sha256', webhookSecret).update(payloadToSign).digest('hex');
+
+ try {
+ const res = await fetch(webhookUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Broadcast-Peer-Id': peerId,
+ 'X-Broadcast-Timestamp': String(timestamp),
+ 'X-Broadcast-Nonce': String(nonce),
+ 'X-Broadcast-Signature': signature,
+ },
+ body: rawBody,
+ });
+
+ rl.close();
+
+ if ( res.ok ) {
+ saveConfig(peerId, webhookSecret, nonce + 1, url);
+ console.log('');
+ console.log('Test event sent successfully. Status:', res.status);
+ const text = await res.text();
+ if ( text ) console.log('Response:', text);
+ process.exit(0);
+ } else {
+ const text = await res.text();
+ console.error('');
+ console.error('Request failed. Status:', res.status, res.statusText);
+ if ( text ) console.error('Response:', text);
+ process.exit(1);
+ }
+ } catch ( err ) {
+ rl.close();
+ console.error('');
+ console.error('Request failed:', err.message);
+ process.exit(1);
+ }
+}
+
+main();