@hahnfeld/msrelay-provider 0.1.2 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,46 +45,24 @@ This plugin provides a tunnel — it makes a local port reachable via a stable H
45
45
  openacp plugin install @hahnfeld/msrelay-provider
46
46
  ```
47
47
 
48
- The install wizard prompts for:
48
+ The install wizard walks you through the full Azure setup — showing the `az` CLI commands for each step and prompting for the values. You'll need the [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) installed.
49
49
 
50
- 1. Azure Relay namespace (e.g., `myrelay.servicebus.windows.net`)
51
- 2. Hybrid Connection name (e.g., `bot-endpoint`)
52
- 3. SAS policy name and key (with `Listen` permission)
53
- 4. Local port to forward to (3978 for Bot Framework, 21420 for OpenACP API server, or any custom port)
50
+ The wizard covers:
54
51
 
55
- ## Azure Setup
52
+ 1. Creating a Relay namespace
53
+ 2. Creating a Hybrid Connection (with client authorization disabled)
54
+ 3. Creating a listen-only SAS policy
55
+ 4. Retrieving the SAS key
56
+ 5. Choosing a local port to forward to
56
57
 
57
- Before installing the plugin, provision these Azure resources:
58
+ On reinstall, existing values are pre-filled so you can press Enter to keep them. The SAS key is masked and can be kept without re-entering.
58
59
 
59
- ```bash
60
- # 1. Create Relay namespace
61
- az relay namespace create \
62
- --resource-group <rg> --name <namespace> --location westus2
63
-
64
- # 2. Create Hybrid Connection with client authorization disabled
65
- # (required if callers can't send SAS headers — Bot Framework, webhooks, etc.)
66
- # NOTE: --requires-client-authorization cannot be changed after creation.
67
- # If you need to change it, delete and recreate the Hybrid Connection.
68
- az relay hyco create \
69
- --resource-group <rg> --namespace-name <namespace> --name my-connection \
70
- --requires-client-authorization false
71
-
72
- # 4. Create listen-only SAS policy (least privilege)
73
- az relay hyco authorization-rule create \
74
- --resource-group <rg> --namespace-name <namespace> \
75
- --hybrid-connection-name my-connection \
76
- --name ListenOnly --rights Listen
77
-
78
- # 5. Retrieve the key
79
- az relay hyco authorization-rule keys list \
80
- --resource-group <rg> --namespace-name <namespace> \
81
- --hybrid-connection-name my-connection --name ListenOnly
82
- ```
60
+ After setup, the wizard tests connectivity by opening a real WebSocket to Azure Relay.
83
61
 
84
- The resulting endpoint URL is:
62
+ The resulting public endpoint URL is:
85
63
 
86
64
  ```
87
- https://<namespace>.servicebus.windows.net/my-connection
65
+ https://<namespace>.servicebus.windows.net/<connection>
88
66
  ```
89
67
 
90
68
  Point your external callers (Bot Framework messaging endpoint, webhook URLs, etc.) at this URL.
@@ -111,13 +89,13 @@ openacp plugin configure @hahnfeld/msrelay-provider
111
89
  | Command | Description |
112
90
  |---------|-------------|
113
91
  | `/relay` | Show connection status, uptime, request/error counts |
114
- | `/relay auth` | Validate SAS key by generating a test token |
92
+ | `/relay auth` | Test connectivity by connecting to Azure Relay |
115
93
 
116
94
  ## Development
117
95
 
118
96
  ```bash
119
97
  pnpm install
120
- pnpm test # Run tests (28 tests)
98
+ pnpm test # Run tests (42 tests)
121
99
  pnpm build # Compile TypeScript
122
100
  pnpm dev # Watch mode
123
101
  pnpm lint # Type-check without emitting
package/dist/plugin.js CHANGED
@@ -10,20 +10,41 @@ function isValidNamespace(v) {
10
10
  function isValidConnectionName(v) {
11
11
  return /^[a-zA-Z0-9._-]+$/.test(v);
12
12
  }
13
- /** Test SAS key by generating a token. No network call — just validates the key format. */
14
- function testSasToken(namespace, connectionName, keyName, keyValue) {
15
- try {
16
- const hycoHttps = require("hyco-https");
17
- const uri = hycoHttps.createRelayListenUri(namespace, connectionName);
18
- const token = hycoHttps.createRelayToken(uri, keyName, keyValue);
19
- if (token && typeof token === "string") {
20
- return { ok: true };
13
+ /** Test connectivity by opening a real WebSocket to Azure Relay. */
14
+ function testRelayConnection(namespace, connectionName, keyName, keyValue, timeoutMs = 10_000) {
15
+ return new Promise((resolve) => {
16
+ try {
17
+ const hycoHttps = require("hyco-https");
18
+ const uri = hycoHttps.createRelayListenUri(namespace, connectionName);
19
+ const server = hycoHttps.createRelayedServer({ server: uri, token: () => hycoHttps.createRelayToken(uri, keyName, keyValue) }, () => { });
20
+ let settled = false;
21
+ const settle = (result) => {
22
+ if (!settled) {
23
+ settled = true;
24
+ try {
25
+ server.close();
26
+ }
27
+ catch { /* ignore */ }
28
+ resolve(result);
29
+ }
30
+ };
31
+ const timer = setTimeout(() => {
32
+ settle({ ok: false, error: "Connection timed out — check namespace, connection name, and network access" });
33
+ }, timeoutMs);
34
+ server.on("listening", () => {
35
+ clearTimeout(timer);
36
+ settle({ ok: true });
37
+ });
38
+ server.on("error", (err) => {
39
+ clearTimeout(timer);
40
+ settle({ ok: false, error: `Connection failed: ${err.message}` });
41
+ });
42
+ server.listen();
21
43
  }
22
- return { ok: false, error: "Token generation returned empty result" };
23
- }
24
- catch (err) {
25
- return { ok: false, error: `Token generation failed: ${err.message}` };
26
- }
44
+ catch (err) {
45
+ resolve({ ok: false, error: `Setup failed: ${err.message}` });
46
+ }
47
+ });
27
48
  }
28
49
  /** Format elapsed time as human-readable duration. */
29
50
  function formatUptime(startedAt) {
@@ -64,7 +85,7 @@ export function createRelayPlugin() {
64
85
  let provider = null;
65
86
  return {
66
87
  name: "@hahnfeld/msrelay-provider",
67
- version: "0.1.2",
88
+ version: "0.1.5",
68
89
  description: "Azure Relay Hybrid Connections tunnel provider — private HTTP tunneling via Azure backbone",
69
90
  essential: false,
70
91
  permissions: [
@@ -77,30 +98,42 @@ export function createRelayPlugin() {
77
98
  // ─── Interactive Install Wizard ──────────────────────────────────────
78
99
  async install(ctx) {
79
100
  const { terminal, settings } = ctx;
80
- terminal.note("This plugin uses Azure Relay Hybrid Connections to expose local ports\n" +
81
- "through Azure's private backbone (ExpressRoute). No public internet exposure.\n" +
82
- "\n" +
83
- "Prerequisites:\n" +
84
- " 1. Azure Relay namespace (e.g., myrelay.servicebus.windows.net)\n" +
85
- " 2. Hybrid Connection with client authorization DISABLED\n" +
86
- " 3. SAS key with Listen permission\n" +
101
+ // Load existing config for pre-filling on reinstall
102
+ const existing = await settings.getAll();
103
+ terminal.note("Azure Relay Hybrid Connections exposes a local port via a stable\n" +
104
+ "HTTPS endpoint on Azure's private backbone — no public internet\n" +
105
+ "exposure, no inbound ports required.\n" +
87
106
  "\n" +
88
- "See SPEC.md Section 12 for Azure CLI setup commands.", "Azure Relay Setup");
89
- // ── Step 1: Relay Namespace ──
90
- const namespace = await terminal.text({
91
- message: "Azure Relay namespace (e.g., myrelay.servicebus.windows.net):",
107
+ "This wizard collects a few names, then gives you the exact Azure\n" +
108
+ "CLI commands to run. You'll need the az CLI installed.", "Azure Relay Setup");
109
+ // ── Step 1: Collect names ──
110
+ const resourceGroup = await terminal.text({
111
+ message: "Azure resource group:",
112
+ defaultValue: existing.resourceGroup ?? undefined,
92
113
  validate: (v) => {
93
- const trimmed = v.trim();
94
- if (!trimmed)
114
+ if (!v.trim())
115
+ return "Resource group is required";
116
+ return undefined;
117
+ },
118
+ });
119
+ const rg = resourceGroup.trim();
120
+ const namespaceInput = await terminal.text({
121
+ message: "Relay namespace name (becomes <name>.servicebus.windows.net):",
122
+ defaultValue: existing.relayNamespace?.replace(".servicebus.windows.net", "") ?? undefined,
123
+ validate: (v) => {
124
+ if (!v.trim())
95
125
  return "Namespace is required";
96
- if (!isValidNamespace(trimmed))
97
- return "Must be <name>.servicebus.windows.net";
98
126
  return undefined;
99
127
  },
100
128
  });
101
- // ── Step 2: Hybrid Connection Name ──
129
+ const nsName = namespaceInput.trim().replace(".servicebus.windows.net", "");
130
+ const ns = `${nsName}.servicebus.windows.net`;
131
+ if (!isValidNamespace(ns)) {
132
+ terminal.log.warning(`"${ns}" doesn't look like a valid namespace — continuing anyway`);
133
+ }
102
134
  const connectionName = await terminal.text({
103
- message: "Hybrid Connection name (e.g., bot-endpoint):",
135
+ message: "Hybrid Connection name:",
136
+ defaultValue: existing.hybridConnectionName ?? "bot-endpoint",
104
137
  validate: (v) => {
105
138
  const trimmed = v.trim();
106
139
  if (!trimmed)
@@ -110,38 +143,72 @@ export function createRelayPlugin() {
110
143
  return undefined;
111
144
  },
112
145
  });
113
- // ── Step 3: SAS Key Name ──
146
+ const hc = connectionName.trim();
114
147
  const keyName = await terminal.text({
115
- message: "SAS policy name:",
116
- defaultValue: "ListenOnly",
148
+ message: "SAS policy name (listen-only auth rule):",
149
+ defaultValue: existing.sasKeyName ?? "ListenOnly",
117
150
  validate: (v) => {
118
151
  if (!v.trim())
119
- return "Key name is required";
152
+ return "Policy name is required";
120
153
  return undefined;
121
154
  },
122
155
  });
123
- // ── Step 4: SAS Key Value ──
124
- const keyValue = await terminal.text({
125
- message: "SAS key value:",
156
+ const sasName = keyName.trim();
157
+ // ── Step 2: Show the public URL and exact az commands ──
158
+ terminal.log.step("Your public endpoint will be:");
159
+ terminal.log.info(` https://${ns}/${hc}/api/messages`);
160
+ terminal.log.info("");
161
+ terminal.log.step("Run these commands to create the Azure resources:");
162
+ terminal.log.info("");
163
+ terminal.log.info(`az relay namespace create \\`);
164
+ terminal.log.info(` --resource-group ${rg} --name ${nsName} --location westus2`);
165
+ terminal.log.info("");
166
+ terminal.log.info(`az relay hyco create \\`);
167
+ terminal.log.info(` --resource-group ${rg} --namespace-name ${nsName} \\`);
168
+ terminal.log.info(` --name ${hc} --requires-client-authorization false`);
169
+ terminal.log.info("");
170
+ terminal.log.info(`az relay hyco authorization-rule create \\`);
171
+ terminal.log.info(` --resource-group ${rg} --namespace-name ${nsName} \\`);
172
+ terminal.log.info(` --hybrid-connection-name ${hc} \\`);
173
+ terminal.log.info(` --name ${sasName} --rights Listen`);
174
+ terminal.log.info("");
175
+ terminal.log.warning("--requires-client-authorization cannot be changed after creation.");
176
+ terminal.log.warning("If you need to change it, delete and recreate the connection.");
177
+ terminal.log.info("");
178
+ // ── Step 3: SAS Key ──
179
+ terminal.log.step("Retrieve the SAS key:");
180
+ terminal.log.info("");
181
+ terminal.log.info(`az relay hyco authorization-rule keys list \\`);
182
+ terminal.log.info(` --resource-group ${rg} --namespace-name ${nsName} \\`);
183
+ terminal.log.info(` --hybrid-connection-name ${hc} --name ${sasName} \\`);
184
+ terminal.log.info(` --query primaryKey -o tsv`);
185
+ terminal.log.info("");
186
+ const hasExistingKey = !!(existing.sasKeyValue);
187
+ let keyValue = await terminal.password({
188
+ message: hasExistingKey
189
+ ? "SAS key value — press Enter to keep current:"
190
+ : "SAS key value (paste the primaryKey):",
126
191
  validate: (v) => {
127
- if (!v.trim())
192
+ if (!(v ?? "").trim() && !hasExistingKey)
128
193
  return "Key value is required";
129
194
  return undefined;
130
195
  },
131
196
  });
132
- // ── Step 5: Port ──
133
- terminal.log.info("");
134
- terminal.note("This tunnel can point to any local port. Common choices:\n" +
135
- "\n" +
136
- " 3978 — Teams adapter (Microsoft Bot Framework default)\n" +
137
- " 21420 — OpenACP API server\n" +
138
- "\n" +
139
- "You can change this later with: openacp plugin configure @hahnfeld/msrelay-provider", "Port Selection");
197
+ if (!(keyValue ?? "").trim() && hasExistingKey) {
198
+ keyValue = existing.sasKeyValue;
199
+ }
200
+ // ── Step 4: Port ──
201
+ const existingPort = existing.port;
140
202
  const envPort = process.env.PORT ? Number(process.env.PORT) : null;
141
203
  const portChoice = await terminal.select({
142
- message: "Which port should the tunnel expose?",
204
+ message: "Which local port should the tunnel forward to?",
143
205
  options: [
144
- ...(envPort ? [{ value: String(envPort), label: `${envPort} (from PORT env var)`, hint: "Current environment" }] : []),
206
+ ...(existingPort && existingPort !== 3978 && existingPort !== 21420
207
+ ? [{ value: String(existingPort), label: `${existingPort} (current config)`, hint: "Previously configured" }]
208
+ : []),
209
+ ...(envPort && envPort !== existingPort
210
+ ? [{ value: String(envPort), label: `${envPort} (from PORT env var)`, hint: "Current environment" }]
211
+ : []),
145
212
  { value: "3978", label: "3978", hint: "Teams adapter (Bot Framework default)" },
146
213
  { value: "21420", label: "21420", hint: "OpenACP API server" },
147
214
  { value: "custom", label: "Other port" },
@@ -151,6 +218,7 @@ export function createRelayPlugin() {
151
218
  if (portChoice === "custom") {
152
219
  const portStr = await terminal.text({
153
220
  message: "Local port to expose:",
221
+ defaultValue: existingPort ? String(existingPort) : undefined,
154
222
  validate: (v) => {
155
223
  const trimmed = v.trim();
156
224
  if (!trimmed)
@@ -166,39 +234,42 @@ export function createRelayPlugin() {
166
234
  else {
167
235
  port = Number(portChoice);
168
236
  }
169
- // ── Step 6: Connectivity Test (optional) ──
237
+ // ── Step 5: Connectivity Test ──
170
238
  const wantTest = await terminal.confirm({
171
- message: "Test connectivity now? (Attempts to connect to the Relay for 10s)",
172
- initialValue: false,
239
+ message: "Test connectivity now? (connects to Azure Relay for up to 10s)",
240
+ initialValue: true,
173
241
  });
174
242
  if (wantTest) {
175
243
  const spin = terminal.spinner();
176
- spin.start("Testing SAS token generation...");
177
- const tokenResult = testSasToken(namespace.trim(), connectionName.trim(), keyName.trim(), keyValue.trim());
178
- if (tokenResult.ok) {
179
- spin.stop("SAS token generated successfully");
244
+ spin.start("Connecting to Azure Relay...");
245
+ const result = await testRelayConnection(ns, hc, sasName, keyValue.trim());
246
+ if (result.ok) {
247
+ spin.stop("Connected to Azure Relay successfully");
180
248
  }
181
249
  else {
182
- spin.fail(tokenResult.error);
250
+ spin.fail(result.error);
183
251
  terminal.log.warning("Installation will continue — you can test connectivity later with /relay auth");
184
252
  }
185
253
  }
186
- // ── Step 7: Save ──
254
+ // ── Step 6: Save ──
187
255
  await settings.setAll({
188
256
  enabled: true,
189
257
  port,
190
- relayNamespace: namespace.trim(),
191
- hybridConnectionName: connectionName.trim(),
192
- sasKeyName: keyName.trim(),
258
+ resourceGroup: rg,
259
+ relayNamespace: ns,
260
+ hybridConnectionName: hc,
261
+ sasKeyName: sasName,
193
262
  sasKeyValue: keyValue.trim(),
194
263
  });
195
264
  terminal.log.success("Azure Relay provider configured!");
196
265
  terminal.log.info("");
197
- terminal.note(`Namespace: ${namespace.trim()}\n` +
198
- `Connection: ${connectionName.trim()}\n` +
199
- `SAS policy: ${keyName.trim()}\n` +
266
+ terminal.note(`Namespace: ${ns}\n` +
267
+ `Connection: ${hc}\n` +
268
+ `SAS policy: ${sasName}\n` +
200
269
  `Port: ${port}\n` +
201
- `Public URL: https://${namespace.trim()}/${connectionName.trim()}`, "Configuration Summary");
270
+ "\n" +
271
+ "Set your Bot Framework messaging endpoint to:\n" +
272
+ ` https://${ns}/${hc}/api/messages`, "Configuration Summary");
202
273
  },
203
274
  // ─── Configure (post-install changes) ────────────────────────────────
204
275
  async configure(ctx) {
@@ -270,7 +341,7 @@ export function createRelayPlugin() {
270
341
  break;
271
342
  }
272
343
  case "sasKeyValue": {
273
- const val = await terminal.text({
344
+ const val = await terminal.password({
274
345
  message: "SAS key value:",
275
346
  validate: (v) => {
276
347
  if (!v.trim())
@@ -308,10 +379,10 @@ export function createRelayPlugin() {
308
379
  }
309
380
  case "auth": {
310
381
  const spin = terminal.spinner();
311
- spin.start("Testing SAS token generation...");
312
- const result = testSasToken(current.relayNamespace, current.hybridConnectionName, current.sasKeyName, current.sasKeyValue);
382
+ spin.start("Connecting to Azure Relay...");
383
+ const result = await testRelayConnection(current.relayNamespace, current.hybridConnectionName, current.sasKeyName, current.sasKeyValue);
313
384
  if (result.ok) {
314
- spin.stop("SAS token generated successfully");
385
+ spin.stop("Connected to Azure Relay successfully");
315
386
  }
316
387
  else {
317
388
  spin.fail(result.error);
@@ -394,9 +465,9 @@ export function createRelayPlugin() {
394
465
  const raw = args.raw.trim();
395
466
  if (raw === "auth") {
396
467
  const cfg = config;
397
- const result = testSasToken(cfg.relayNamespace, cfg.hybridConnectionName, cfg.sasKeyName, cfg.sasKeyValue);
468
+ const result = await testRelayConnection(cfg.relayNamespace, cfg.hybridConnectionName, cfg.sasKeyName, cfg.sasKeyValue);
398
469
  if (result.ok) {
399
- return { type: "text", text: "SAS token generation: OK" };
470
+ return { type: "text", text: "Azure Relay connectivity: OK" };
400
471
  }
401
472
  return { type: "error", message: result.error };
402
473
  }
package/dist/provider.js CHANGED
@@ -234,12 +234,18 @@ export class AzureRelayProvider {
234
234
  this.sendError(relayRes, 400, "Bad Request", `Invalid request URL: ${rawUrl}`);
235
235
  return;
236
236
  }
237
+ // Azure Relay prepends the Hybrid Connection name to the path.
238
+ // e.g., /bot-endpoint/api/messages → /api/messages
239
+ const prefix = `/${this.config.hybridConnectionName}`;
240
+ const forwardPath = rawUrl.startsWith(prefix)
241
+ ? (rawUrl.slice(prefix.length) || "/")
242
+ : rawUrl;
237
243
  const forwardHeaders = stripHopByHop(relayReq.headers);
238
244
  forwardHeaders["host"] = `localhost:${this.localPort}`;
239
245
  const localReq = httpRequest({
240
246
  hostname: "localhost",
241
247
  port: this.localPort,
242
- path: rawUrl,
248
+ path: forwardPath,
243
249
  method: relayReq.method,
244
250
  headers: forwardHeaders,
245
251
  timeout: LOCAL_FORWARD_TIMEOUT_MS,
package/dist/types.d.ts CHANGED
@@ -4,6 +4,8 @@ export interface AzureRelayConfig {
4
4
  enabled: boolean;
5
5
  /** Local port to forward requests to. Default: PORT env var or 3978. */
6
6
  port: number | null;
7
+ /** Azure resource group (saved for wizard pre-fill, not used at runtime). */
8
+ resourceGroup?: string;
7
9
  /** Azure Relay namespace (e.g., "myrelay.servicebus.windows.net"). */
8
10
  relayNamespace: string;
9
11
  /** Hybrid Connection name (e.g., "bot-endpoint"). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hahnfeld/msrelay-provider",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "Azure Relay Hybrid Connections tunnel provider plugin for OpenACP",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",