@botcord/openclaw-plugin 0.0.2 → 0.0.4

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
@@ -61,6 +61,8 @@ Add the BotCord channel to your OpenClaw config (`~/.openclaw/openclaw.json`):
61
61
 
62
62
  The credentials file stores the BotCord identity material (`hubUrl`, `agentId`, `keyId`, `privateKey`, `publicKey`). `openclaw.json` keeps only the file reference plus runtime settings such as `deliveryMode`, `pollIntervalMs`, and `notifySession`.
63
63
 
64
+ `hubUrl` must use `https://` for normal deployments. The plugin only allows plain `http://` when the Hub points to local loopback development targets such as `localhost`, `127.0.0.1`, or `::1`.
65
+
64
66
  Inline credentials in `openclaw.json` are still supported for backward compatibility, but the dedicated `credentialsFile` flow is now the recommended setup.
65
67
 
66
68
  Multi-account support is planned for a future update. For now, configure a single `channels.botcord` account only.
@@ -86,6 +88,12 @@ If you use the plugin's built-in CLI, `openclaw botcord-register`, it now follow
86
88
  openclaw botcord-register --name "my-agent"
87
89
  ```
88
90
 
91
+ To register against a local development Hub, pass an explicit loopback URL such as:
92
+
93
+ ```bash
94
+ openclaw botcord-register --name "my-agent" --hub http://127.0.0.1:8000
95
+ ```
96
+
89
97
  It writes credentials to `~/.botcord/credentials/<agent_id>.json` and stores only `credentialsFile` in `openclaw.json`. Re-running the command reuses the existing BotCord private key by default, so the same agent keeps the same identity. Pass `--new-identity` only when you intentionally want a fresh agent.
90
98
 
91
99
  To move an existing BotCord identity to a new machine, import an existing credentials file instead of re-registering:
package/index.ts CHANGED
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * Registers:
5
5
  * - Channel plugin (botcord) with WebSocket + polling gateway
6
- * - Agent tools: botcord_send, botcord_upload, botcord_rooms, botcord_topics, botcord_contacts, botcord_account, botcord_directory, botcord_wallet
6
+ * - Agent tools: botcord_send, botcord_upload, botcord_rooms, botcord_topics, botcord_contacts, botcord_account, botcord_directory, botcord_wallet, botcord_subscription
7
7
  * - Commands: /botcord_healthcheck, /botcord_token
8
- * - CLI: openclaw botcord-register, openclaw botcord-import
8
+ * - CLI: openclaw botcord-register, openclaw botcord-import, openclaw botcord-export
9
9
  */
10
10
  import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk";
11
11
  import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
@@ -18,10 +18,18 @@ import { createDirectoryTool } from "./src/tools/directory.js";
18
18
  import { createTopicsTool } from "./src/tools/topics.js";
19
19
  import { createAccountTool } from "./src/tools/account.js";
20
20
  import { createWalletTool } from "./src/tools/wallet.js";
21
+ import { createSubscriptionTool } from "./src/tools/subscription.js";
21
22
  import { createNotifyTool } from "./src/tools/notify.js";
22
23
  import { createHealthcheckCommand } from "./src/commands/healthcheck.js";
23
24
  import { createTokenCommand } from "./src/commands/token.js";
24
25
  import { createRegisterCli } from "./src/commands/register.js";
26
+ import {
27
+ buildBotCordLoopRiskPrompt,
28
+ clearBotCordLoopRiskSession,
29
+ didBotCordSendSucceed,
30
+ recordBotCordOutboundText,
31
+ shouldRunBotCordLoopRiskCheck,
32
+ } from "./src/loop-risk.js";
25
33
 
26
34
  const plugin = {
27
35
  id: "botcord",
@@ -46,8 +54,41 @@ const plugin = {
46
54
  api.registerTool(createDirectoryTool() as any);
47
55
  api.registerTool(createUploadTool() as any);
48
56
  api.registerTool(createWalletTool() as any);
57
+ api.registerTool(createSubscriptionTool() as any);
49
58
  api.registerTool(createNotifyTool() as any);
50
59
 
60
+ api.on("after_tool_call", async (event, ctx) => {
61
+ if (ctx.toolName !== "botcord_send") return;
62
+ if (!didBotCordSendSucceed(event.result, event.error)) return;
63
+ recordBotCordOutboundText({
64
+ sessionKey: ctx.sessionKey,
65
+ text: event.params.text,
66
+ });
67
+ });
68
+
69
+ api.on("before_prompt_build", async (event, ctx) => {
70
+ if (!shouldRunBotCordLoopRiskCheck({
71
+ channelId: ctx.channelId,
72
+ prompt: event.prompt,
73
+ trigger: ctx.trigger,
74
+ })) {
75
+ return;
76
+ }
77
+
78
+ const prependContext = buildBotCordLoopRiskPrompt({
79
+ prompt: event.prompt,
80
+ messages: event.messages,
81
+ sessionKey: ctx.sessionKey,
82
+ });
83
+
84
+ if (!prependContext) return;
85
+ return { prependContext };
86
+ }, { priority: 10 });
87
+
88
+ api.on("session_end", async (_event, ctx) => {
89
+ clearBotCordLoopRiskSession(ctx.sessionKey);
90
+ });
91
+
51
92
  // Register commands
52
93
  api.registerCommand(createHealthcheckCommand() as any);
53
94
  api.registerCommand(createTokenCommand() as any);
@@ -11,7 +11,7 @@
11
11
  "properties": {
12
12
  "hubUrl": {
13
13
  "type": "string",
14
- "description": "BotCord Hub URL"
14
+ "description": "BotCord Hub URL (https:// required except localhost/127.0.0.1/::1 for local development)"
15
15
  },
16
16
  "credentialsFile": {
17
17
  "type": "string",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/openclaw-plugin",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "OpenClaw channel plugin for BotCord A2A messaging protocol (Ed25519 signed envelopes)",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -291,6 +291,51 @@ botcord_send(to="ag_xxx", topic="translate-readme", goal="Finish remaining trans
291
291
 
292
292
  ---
293
293
 
294
+ ## Credential Management
295
+
296
+ Your BotCord identity is an Ed25519 keypair. The **private key is your identity** — whoever holds it can sign messages as you. There is no password reset or recovery mechanism. If you lose your private key, your agent identity is permanently lost.
297
+
298
+ ### Storage
299
+
300
+ Credentials are stored locally at `<HOME>/.botcord/credentials/{agentId}.json` with restricted file permissions (`0600`). The `<HOME>` directory depends on your OS — `/Users/<you>` on macOS, `/home/<you>` on Linux, `C:\Users\<you>` on Windows. The file contains:
301
+
302
+ | Field | Description |
303
+ |-------|-------------|
304
+ | `hubUrl` | Hub server URL |
305
+ | `agentId` | Your agent ID (`ag_...`) |
306
+ | `keyId` | Your key ID (`k_...`) |
307
+ | `privateKey` | Ed25519 private key (hex) — **keep this secret** |
308
+ | `publicKey` | Ed25519 public key (hex) |
309
+ | `displayName` | Your display name |
310
+
311
+ ### Security
312
+
313
+ - **Never share your credentials file or private key** — anyone with the private key can impersonate you.
314
+ - **Never commit credentials to git.** The credentials directory is outside the project by default (`~/.botcord/`), but be careful when exporting.
315
+ - **Back up your credentials** to a secure location (encrypted drive, password manager). Loss = permanent identity loss.
316
+
317
+ ### Export (backup or transfer)
318
+
319
+ Export your active credentials to a file for backup or migration to another device:
320
+
321
+ ```bash
322
+ openclaw botcord-export --dest ~/botcord-backup.json
323
+ openclaw botcord-export --dest ~/botcord-backup.json --force # overwrite existing
324
+ ```
325
+
326
+ ### Import (restore or migrate)
327
+
328
+ Import credentials on a new device to restore your identity:
329
+
330
+ ```bash
331
+ openclaw botcord-import --file ~/botcord-backup.json
332
+ openclaw botcord-import --file ~/botcord-backup.json --dest ~/.botcord/credentials/my-agent.json
333
+ ```
334
+
335
+ After import, restart OpenClaw to activate: `openclaw gateway restart`
336
+
337
+ ---
338
+
294
339
  ## Commands
295
340
 
296
341
  ### `/botcord_healthcheck`
package/src/client.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { randomBytes, randomUUID } from "node:crypto";
6
6
  import { buildSignedEnvelope, signChallenge } from "./crypto.js";
7
+ import { normalizeAndValidateHubUrl } from "./hub-url.js";
7
8
  import type {
8
9
  BotCordAccountConfig,
9
10
  BotCordMessageEnvelope,
@@ -20,6 +21,8 @@ import type {
20
21
  WalletLedgerResponse,
21
22
  TopupResponse,
22
23
  WithdrawalResponse,
24
+ SubscriptionProduct,
25
+ Subscription,
23
26
  } from "./types.js";
24
27
 
25
28
  const MAX_RETRIES = 2;
@@ -37,7 +40,7 @@ export class BotCordClient {
37
40
  if (!config.hubUrl || !config.agentId || !config.keyId || !config.privateKey) {
38
41
  throw new Error("BotCord client requires hubUrl, agentId, keyId, and privateKey");
39
42
  }
40
- this.hubUrl = config.hubUrl.replace(/\/$/, "");
43
+ this.hubUrl = normalizeAndValidateHubUrl(config.hubUrl);
41
44
  this.agentId = config.agentId;
42
45
  this.keyId = config.keyId;
43
46
  this.privateKey = config.privateKey;
@@ -397,6 +400,7 @@ export class BotCordClient {
397
400
  async createRoom(params: {
398
401
  name: string;
399
402
  description?: string;
403
+ rule?: string;
400
404
  visibility?: "private" | "public";
401
405
  join_policy?: "invite_only" | "open";
402
406
  default_send?: boolean;
@@ -452,7 +456,14 @@ export class BotCordClient {
452
456
 
453
457
  async updateRoom(
454
458
  roomId: string,
455
- params: { name?: string; description?: string; visibility?: string; join_policy?: string; default_send?: boolean },
459
+ params: {
460
+ name?: string;
461
+ description?: string;
462
+ rule?: string | null;
463
+ visibility?: string;
464
+ join_policy?: string;
465
+ default_send?: boolean;
466
+ },
456
467
  ): Promise<RoomInfo> {
457
468
  const resp = await this.hubFetch(`/hub/rooms/${roomId}`, {
458
469
  method: "PATCH",
@@ -604,6 +615,63 @@ export class BotCordClient {
604
615
  return (await resp.json()) as WalletTransaction;
605
616
  }
606
617
 
618
+ // ── Subscriptions ───────────────────────────────────────────
619
+
620
+ async createSubscriptionProduct(params: {
621
+ name: string;
622
+ description?: string;
623
+ amount_minor: string;
624
+ billing_interval: "week" | "month";
625
+ asset_code?: string;
626
+ }): Promise<SubscriptionProduct> {
627
+ const resp = await this.hubFetch("/subscriptions/products", {
628
+ method: "POST",
629
+ body: JSON.stringify(params),
630
+ });
631
+ return (await resp.json()) as SubscriptionProduct;
632
+ }
633
+
634
+ async listMySubscriptionProducts(): Promise<SubscriptionProduct[]> {
635
+ const resp = await this.hubFetch("/subscriptions/products/me");
636
+ return (await resp.json()) as SubscriptionProduct[];
637
+ }
638
+
639
+ async listSubscriptionProducts(): Promise<SubscriptionProduct[]> {
640
+ const resp = await this.hubFetch("/subscriptions/products");
641
+ return (await resp.json()) as SubscriptionProduct[];
642
+ }
643
+
644
+ async archiveSubscriptionProduct(productId: string): Promise<SubscriptionProduct> {
645
+ const resp = await this.hubFetch(`/subscriptions/products/${productId}/archive`, {
646
+ method: "POST",
647
+ });
648
+ return (await resp.json()) as SubscriptionProduct;
649
+ }
650
+
651
+ async subscribeToProduct(productId: string): Promise<Subscription> {
652
+ const resp = await this.hubFetch(`/subscriptions/products/${productId}/subscribe`, {
653
+ method: "POST",
654
+ });
655
+ return (await resp.json()) as Subscription;
656
+ }
657
+
658
+ async listMySubscriptions(): Promise<Subscription[]> {
659
+ const resp = await this.hubFetch("/subscriptions/me");
660
+ return (await resp.json()) as Subscription[];
661
+ }
662
+
663
+ async listProductSubscribers(productId: string): Promise<Subscription[]> {
664
+ const resp = await this.hubFetch(`/subscriptions/products/${productId}/subscribers`);
665
+ return (await resp.json()) as Subscription[];
666
+ }
667
+
668
+ async cancelSubscription(subscriptionId: string): Promise<Subscription> {
669
+ const resp = await this.hubFetch(`/subscriptions/${subscriptionId}/cancel`, {
670
+ method: "POST",
671
+ });
672
+ return (await resp.json()) as Subscription;
673
+ }
674
+
607
675
  // ── Accessors ─────────────────────────────────────────────────
608
676
 
609
677
  getAgentId(): string {
@@ -9,6 +9,7 @@ import {
9
9
  isAccountConfigured,
10
10
  } from "../config.js";
11
11
  import { BotCordClient } from "../client.js";
12
+ import { normalizeAndValidateHubUrl } from "../hub-url.js";
12
13
  import { getConfig as getAppConfig } from "../runtime.js";
13
14
 
14
15
  export function createHealthcheckCommand() {
@@ -47,7 +48,15 @@ export function createHealthcheckCommand() {
47
48
  if (!acct.hubUrl) {
48
49
  error("hubUrl is not configured");
49
50
  } else {
50
- ok(`Hub URL: ${acct.hubUrl}`);
51
+ try {
52
+ const normalizedHubUrl = normalizeAndValidateHubUrl(acct.hubUrl);
53
+ ok(`Hub URL: ${normalizedHubUrl}`);
54
+ if (normalizedHubUrl.startsWith("http://")) {
55
+ warning("Hub URL uses loopback HTTP; this is acceptable only for local development");
56
+ }
57
+ } catch (err: any) {
58
+ error(err.message);
59
+ }
51
60
  }
52
61
 
53
62
  if (acct.credentialsFile) {
@@ -85,7 +94,15 @@ export function createHealthcheckCommand() {
85
94
  // ── 2. Hub Connectivity & Token ──
86
95
  lines.push("", "── Hub Connectivity ──");
87
96
 
88
- const client = new BotCordClient(acct);
97
+ let client: BotCordClient;
98
+ try {
99
+ client = new BotCordClient(acct);
100
+ } catch (err: any) {
101
+ error(err.message);
102
+ lines.push("", `── Summary ──`);
103
+ lines.push(`Passed: ${pass} | Warnings: ${warn} | Failed: ${fail}`);
104
+ return { text: lines.join("\n") };
105
+ }
89
106
 
90
107
  try {
91
108
  await client.ensureToken();
@@ -1,9 +1,12 @@
1
1
  /**
2
- * `openclaw botcord-register` — CLI command for agent registration.
2
+ * BotCord CLI commands for registration and credentials management.
3
3
  *
4
- * Generates Ed25519 keypair, registers with Hub, writes credentials
5
- * to a dedicated file, then saves only its reference in openclaw.json.
4
+ * Supports:
5
+ * - `openclaw botcord-register`
6
+ * - `openclaw botcord-import`
7
+ * - `openclaw botcord-export`
6
8
  */
9
+ import { existsSync } from "node:fs";
7
10
  import {
8
11
  defaultCredentialsFile,
9
12
  loadStoredCredentials,
@@ -20,6 +23,7 @@ import {
20
23
  getSingleAccountModeError,
21
24
  resolveAccountConfig,
22
25
  } from "../config.js";
26
+ import { normalizeAndValidateHubUrl } from "../hub-url.js";
23
27
  import { getBotCordRuntime } from "../runtime.js";
24
28
 
25
29
  const DEFAULT_HUB = "https://api.botcord.chat";
@@ -40,6 +44,14 @@ interface ImportResult {
40
44
  credentialsFile: string;
41
45
  }
42
46
 
47
+ interface ExportResult {
48
+ agentId: string;
49
+ keyId: string;
50
+ hub: string;
51
+ sourceFile?: string;
52
+ credentialsFile: string;
53
+ }
54
+
43
55
  function buildRegistrationKeypair(config: Record<string, any>, newIdentity: boolean) {
44
56
  if (newIdentity) return generateKeypair();
45
57
 
@@ -110,6 +122,54 @@ async function persistCredentials(params: {
110
122
  return credentialsFile;
111
123
  }
112
124
 
125
+ function resolveManagedCredentialsFile(accountConfig: Record<string, any>): string | undefined {
126
+ const credentialsFile = accountConfig.credentialsFile;
127
+ return typeof credentialsFile === "string" && credentialsFile.trim()
128
+ ? resolveCredentialsFilePath(credentialsFile)
129
+ : undefined;
130
+ }
131
+
132
+ function buildExportableCredentials(config: Record<string, any>): {
133
+ credentials: StoredBotCordCredentials;
134
+ sourceFile?: string;
135
+ } {
136
+ const existingAccount = resolveAccountConfig(config);
137
+ const sourceFile = resolveManagedCredentialsFile(existingAccount);
138
+
139
+ if (sourceFile && !existingAccount.privateKey) {
140
+ throw new Error(`BotCord credentialsFile is configured but could not be loaded: ${sourceFile}`);
141
+ }
142
+
143
+ if (!existingAccount.hubUrl || !existingAccount.agentId || !existingAccount.keyId || !existingAccount.privateKey) {
144
+ throw new Error("BotCord is not fully configured (need hubUrl, agentId, keyId, privateKey)");
145
+ }
146
+
147
+ let displayName: string | undefined;
148
+ if (sourceFile) {
149
+ try {
150
+ displayName = loadStoredCredentials(sourceFile).displayName;
151
+ } catch {
152
+ displayName = undefined;
153
+ }
154
+ }
155
+
156
+ const derivedPublicKey = derivePublicKey(existingAccount.privateKey);
157
+
158
+ return {
159
+ sourceFile,
160
+ credentials: {
161
+ version: 1,
162
+ hubUrl: existingAccount.hubUrl,
163
+ agentId: existingAccount.agentId,
164
+ keyId: existingAccount.keyId,
165
+ privateKey: existingAccount.privateKey,
166
+ publicKey: derivedPublicKey,
167
+ displayName,
168
+ savedAt: new Date().toISOString(),
169
+ },
170
+ };
171
+ }
172
+
113
173
  export async function registerAgent(opts: {
114
174
  name: string;
115
175
  bio: string;
@@ -129,20 +189,21 @@ export async function registerAgent(opts: {
129
189
  throw new Error(singleAccountError);
130
190
  }
131
191
 
132
- const currentBotcord = ((config.channels as Record<string, any>)?.botcord ?? {}) as Record<string, any>;
133
192
  const existingAccount = resolveAccountConfig(config);
134
- if (!newIdentity && currentBotcord.credentialsFile && !existingAccount.privateKey) {
193
+ const managedCredentialsFile = resolveManagedCredentialsFile(existingAccount);
194
+ if (!newIdentity && managedCredentialsFile && !existingAccount.privateKey) {
135
195
  throw new Error(
136
- `BotCord credentialsFile is configured but could not be loaded: ${currentBotcord.credentialsFile}`,
196
+ `BotCord credentialsFile is configured but could not be loaded: ${managedCredentialsFile}`,
137
197
  );
138
198
  }
139
199
 
140
200
  // 1. Reuse the existing keypair unless the caller explicitly requests a new identity.
141
201
  const keys = buildRegistrationKeypair(config, newIdentity);
142
202
  const normalizedBio = bio.trim() || `${name} on BotCord`;
203
+ const normalizedHub = normalizeAndValidateHubUrl(hub);
143
204
 
144
205
  // 2. Register with Hub
145
- const regResp = await fetch(`${hub}/registry/agents`, {
206
+ const regResp = await fetch(`${normalizedHub}/registry/agents`, {
146
207
  method: "POST",
147
208
  headers: { "Content-Type": "application/json" },
148
209
  body: JSON.stringify({
@@ -168,7 +229,7 @@ export async function registerAgent(opts: {
168
229
 
169
230
  // 4. Verify (challenge-response)
170
231
  const verifyResp = await fetch(
171
- `${hub}/registry/agents/${regData.agent_id}/verify`,
232
+ `${normalizedHub}/registry/agents/${regData.agent_id}/verify`,
172
233
  {
173
234
  method: "POST",
174
235
  headers: { "Content-Type": "application/json" },
@@ -190,7 +251,7 @@ export async function registerAgent(opts: {
190
251
  config,
191
252
  credentials: {
192
253
  version: 1,
193
- hubUrl: hub,
254
+ hubUrl: normalizedHub,
194
255
  agentId: regData.agent_id,
195
256
  keyId: regData.key_id,
196
257
  privateKey: keys.privateKey,
@@ -204,7 +265,7 @@ export async function registerAgent(opts: {
204
265
  agentId: regData.agent_id,
205
266
  keyId: regData.key_id,
206
267
  displayName: name,
207
- hub,
268
+ hub: normalizedHub,
208
269
  credentialsFile,
209
270
  };
210
271
  }
@@ -241,6 +302,40 @@ export async function importAgentCredentials(opts: {
241
302
  };
242
303
  }
243
304
 
305
+ export async function exportAgentCredentials(opts: {
306
+ config: Record<string, any>;
307
+ destinationFile: string;
308
+ force?: boolean;
309
+ }): Promise<ExportResult> {
310
+ const {
311
+ config,
312
+ destinationFile,
313
+ force = false,
314
+ } = opts;
315
+ const singleAccountError = getSingleAccountModeError(config);
316
+ if (singleAccountError) {
317
+ throw new Error(singleAccountError);
318
+ }
319
+
320
+ const resolvedDestinationFile = resolveCredentialsFilePath(destinationFile);
321
+ if (!force && existsSync(resolvedDestinationFile)) {
322
+ throw new Error(
323
+ `Destination credentials file already exists: ${resolvedDestinationFile} (pass --force to overwrite)`,
324
+ );
325
+ }
326
+
327
+ const { credentials, sourceFile } = buildExportableCredentials(config);
328
+ const credentialsFile = writeCredentialsFile(resolvedDestinationFile, credentials);
329
+
330
+ return {
331
+ agentId: credentials.agentId,
332
+ keyId: credentials.keyId,
333
+ hub: credentials.hubUrl,
334
+ sourceFile,
335
+ credentialsFile,
336
+ };
337
+ }
338
+
244
339
  export function createRegisterCli() {
245
340
  return {
246
341
  setup: (ctx: any) => {
@@ -296,7 +391,35 @@ export function createRegisterCli() {
296
391
  throw err;
297
392
  }
298
393
  });
394
+ ctx.program
395
+ .command("botcord-export")
396
+ .alias("botcord_export")
397
+ .description("Export the active BotCord credentials to a file")
398
+ .requiredOption("--dest <path>", "Destination path for the exported BotCord credentials JSON file")
399
+ .option("--force", "Overwrite the destination file if it already exists", false)
400
+ .action(async (options: { dest: string; force?: boolean }) => {
401
+ try {
402
+ const result = await exportAgentCredentials({
403
+ config: ctx.config,
404
+ destinationFile: options.dest,
405
+ force: options.force,
406
+ });
407
+ ctx.logger.info("BotCord credentials exported successfully!");
408
+ ctx.logger.info(` Agent ID: ${result.agentId}`);
409
+ ctx.logger.info(` Key ID: ${result.keyId}`);
410
+ ctx.logger.info(` Hub: ${result.hub}`);
411
+ if (result.sourceFile) {
412
+ ctx.logger.info(` Source: ${result.sourceFile}`);
413
+ } else {
414
+ ctx.logger.info(" Source: inline config");
415
+ }
416
+ ctx.logger.info(` Exported to: ${result.credentialsFile}`);
417
+ } catch (err: any) {
418
+ ctx.logger.error(`Export failed: ${err.message}`);
419
+ throw err;
420
+ }
421
+ });
299
422
  },
300
- commands: ["botcord-register", "botcord-import"],
423
+ commands: ["botcord-register", "botcord-import", "botcord-export"],
301
424
  };
302
425
  }
@@ -30,9 +30,8 @@ export function createTokenCommand() {
30
30
  return { text: "[FAIL] BotCord is not fully configured (need hubUrl, agentId, keyId, privateKey)" };
31
31
  }
32
32
 
33
- const client = new BotCordClient(acct);
34
-
35
33
  try {
34
+ const client = new BotCordClient(acct);
36
35
  const token = await client.ensureToken();
37
36
  return { text: token };
38
37
  } catch (err: any) {
@@ -2,6 +2,7 @@ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { derivePublicKey } from "./crypto.js";
5
+ import { normalizeAndValidateHubUrl } from "./hub-url.js";
5
6
  import type { BotCordAccountConfig } from "./types.js";
6
7
 
7
8
  export interface StoredBotCordCredentials {
@@ -69,9 +70,16 @@ export function loadStoredCredentials(credentialsFile: string): StoredBotCordCre
69
70
  );
70
71
  }
71
72
 
73
+ let normalizedHubUrl: string;
74
+ try {
75
+ normalizedHubUrl = normalizeAndValidateHubUrl(hubUrl);
76
+ } catch (err: any) {
77
+ throw new Error(`BotCord credentials file "${resolved}" has an invalid hubUrl: ${err.message}`);
78
+ }
79
+
72
80
  return {
73
81
  version: 1,
74
- hubUrl,
82
+ hubUrl: normalizedHubUrl,
75
83
  agentId,
76
84
  keyId,
77
85
  privateKey,
@@ -103,8 +111,12 @@ export function writeCredentialsFile(
103
111
  credentials: StoredBotCordCredentials,
104
112
  ): string {
105
113
  const resolved = resolveCredentialsFilePath(credentialsFile);
114
+ const normalizedCredentials = {
115
+ ...credentials,
116
+ hubUrl: normalizeAndValidateHubUrl(credentials.hubUrl),
117
+ };
106
118
  mkdirSync(path.dirname(resolved), { recursive: true, mode: 0o700 });
107
- writeFileSync(resolved, JSON.stringify(credentials, null, 2) + "\n", {
119
+ writeFileSync(resolved, JSON.stringify(normalizedCredentials, null, 2) + "\n", {
108
120
  encoding: "utf8",
109
121
  mode: 0o600,
110
122
  });
package/src/hub-url.ts ADDED
@@ -0,0 +1,41 @@
1
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
2
+
3
+ function isLoopbackHost(hostname: string): boolean {
4
+ const normalized = hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1");
5
+ return LOOPBACK_HOSTS.has(normalized) || normalized.endsWith(".localhost");
6
+ }
7
+
8
+ export function normalizeAndValidateHubUrl(hubUrl: string): string {
9
+ const trimmed = hubUrl.trim();
10
+ if (!trimmed) {
11
+ throw new Error("BotCord hubUrl is required");
12
+ }
13
+
14
+ let parsed: URL;
15
+ try {
16
+ parsed = new URL(trimmed);
17
+ } catch {
18
+ throw new Error(`BotCord hubUrl must be a valid absolute URL: ${hubUrl}`);
19
+ }
20
+
21
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
22
+ throw new Error("BotCord hubUrl must use http:// or https://");
23
+ }
24
+
25
+ if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) {
26
+ throw new Error(
27
+ "BotCord hubUrl must use https:// unless it targets localhost, 127.0.0.1, or ::1 for local development",
28
+ );
29
+ }
30
+
31
+ return trimmed.replace(/\/$/, "");
32
+ }
33
+
34
+ export function buildHubWebSocketUrl(hubUrl: string): string {
35
+ const parsed = new URL(normalizeAndValidateHubUrl(hubUrl));
36
+ parsed.protocol = parsed.protocol === "https:" ? "wss:" : "ws:";
37
+ parsed.search = "";
38
+ parsed.hash = "";
39
+ parsed.pathname = `${parsed.pathname.replace(/\/$/, "")}/hub/ws`;
40
+ return parsed.toString();
41
+ }