nbound 1.0.2 → 1.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.
@@ -5223,7 +5223,7 @@ var require_websocket = __commonJS((exports, module) => {
5223
5223
  var http = __require("http");
5224
5224
  var net = __require("net");
5225
5225
  var tls = __require("tls");
5226
- var { randomBytes, createHash } = __require("crypto");
5226
+ var { randomBytes: randomBytes2, createHash } = __require("crypto");
5227
5227
  var { Duplex, Readable } = __require("stream");
5228
5228
  var { URL: URL2 } = __require("url");
5229
5229
  var PerMessageDeflate = require_permessage_deflate();
@@ -5632,7 +5632,7 @@ var require_websocket = __commonJS((exports, module) => {
5632
5632
  }
5633
5633
  }
5634
5634
  const defaultPort = isSecure ? 443 : 80;
5635
- const key = randomBytes(16).toString("base64");
5635
+ const key = randomBytes2(16).toString("base64");
5636
5636
  const request2 = isSecure ? https.request : http.request;
5637
5637
  const protocolSet = new Set;
5638
5638
  let perMessageDeflate;
@@ -6971,17 +6971,45 @@ function nanoid(size = 21) {
6971
6971
  }
6972
6972
 
6973
6973
  // src/lib/config.ts
6974
- import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
6974
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, renameSync, chmodSync } from "node:fs";
6975
6975
  import { homedir } from "node:os";
6976
6976
  import { join } from "node:path";
6977
+ import { randomBytes } from "node:crypto";
6977
6978
  var CONFIG_DIR = ".nbound";
6978
6979
  var CONFIG_FILE = "config.json";
6980
+ var EXPIRY_WARNING_MS = 7 * 24 * 60 * 60 * 1000;
6979
6981
  function getConfigDir() {
6980
6982
  return join(homedir(), CONFIG_DIR);
6981
6983
  }
6982
6984
  function getConfigPath() {
6983
6985
  return join(getConfigDir(), CONFIG_FILE);
6984
6986
  }
6987
+ function checkTokenExpiry(config) {
6988
+ if (!config.expiresAt) {
6989
+ return "valid";
6990
+ }
6991
+ const expiresAt = new Date(config.expiresAt).getTime();
6992
+ const now = Date.now();
6993
+ if (now >= expiresAt) {
6994
+ return "expired";
6995
+ }
6996
+ if (expiresAt - now <= EXPIRY_WARNING_MS) {
6997
+ return "expiring_soon";
6998
+ }
6999
+ return "valid";
7000
+ }
7001
+ function getDaysUntilExpiry(config) {
7002
+ if (!config.expiresAt) {
7003
+ return null;
7004
+ }
7005
+ const expiresAt = new Date(config.expiresAt).getTime();
7006
+ const now = Date.now();
7007
+ const msRemaining = expiresAt - now;
7008
+ if (msRemaining <= 0) {
7009
+ return 0;
7010
+ }
7011
+ return Math.ceil(msRemaining / (24 * 60 * 60 * 1000));
7012
+ }
6985
7013
  function loadConfig() {
6986
7014
  const envToken = process.env.NBOUND_TOKEN;
6987
7015
  if (envToken) {
@@ -6999,7 +7027,12 @@ function loadConfig() {
6999
7027
  }
7000
7028
  try {
7001
7029
  const content = readFileSync(configPath, "utf-8");
7002
- return JSON.parse(content);
7030
+ const config = JSON.parse(content);
7031
+ const expiry = checkTokenExpiry(config);
7032
+ if (expiry === "expired") {
7033
+ return null;
7034
+ }
7035
+ return config;
7003
7036
  } catch {
7004
7037
  return null;
7005
7038
  }
@@ -7007,10 +7040,30 @@ function loadConfig() {
7007
7040
  function saveConfig(config) {
7008
7041
  const configDir = getConfigDir();
7009
7042
  const configPath = getConfigPath();
7010
- if (!existsSync(configDir)) {
7011
- mkdirSync(configDir, { recursive: true, mode: 448 });
7043
+ try {
7044
+ if (!existsSync(configDir)) {
7045
+ mkdirSync(configDir, { recursive: true, mode: 448 });
7046
+ } else {
7047
+ chmodSync(configDir, 448);
7048
+ }
7049
+ } catch (err) {
7050
+ if (!existsSync(configDir)) {
7051
+ throw err;
7052
+ }
7053
+ }
7054
+ const tempPath = join(configDir, `.config.${randomBytes(8).toString("hex")}.tmp`);
7055
+ try {
7056
+ writeFileSync(tempPath, JSON.stringify(config, null, 2), { mode: 384 });
7057
+ chmodSync(tempPath, 384);
7058
+ renameSync(tempPath, configPath);
7059
+ } catch (err) {
7060
+ try {
7061
+ if (existsSync(tempPath)) {
7062
+ unlinkSync(tempPath);
7063
+ }
7064
+ } catch {}
7065
+ throw err;
7012
7066
  }
7013
- writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
7014
7067
  }
7015
7068
  function deleteConfig() {
7016
7069
  const configPath = getConfigPath();
@@ -7032,6 +7085,10 @@ function getWsUrl() {
7032
7085
  }
7033
7086
 
7034
7087
  // src/lib/api.ts
7088
+ var REQUEST_TIMEOUT_MS = 1e4;
7089
+ var MAX_RETRIES = 3;
7090
+ var RETRY_DELAYS = [1000, 2000, 4000];
7091
+
7035
7092
  class ApiError extends Error {
7036
7093
  status;
7037
7094
  code;
@@ -7042,22 +7099,57 @@ class ApiError extends Error {
7042
7099
  this.name = "ApiError";
7043
7100
  }
7044
7101
  }
7102
+ function sleep(ms) {
7103
+ return new Promise((resolve) => setTimeout(resolve, ms));
7104
+ }
7105
+ function isRetryable(status) {
7106
+ return status >= 500 && status < 600;
7107
+ }
7045
7108
  async function request(path2, token, options = {}) {
7046
7109
  const apiUrl = getApiUrl();
7047
7110
  const url = `${apiUrl}${path2}`;
7048
- const response = await fetch(url, {
7049
- ...options,
7050
- headers: {
7051
- "Content-Type": "application/json",
7052
- Authorization: `Bearer ${token}`,
7053
- ...options.headers
7111
+ let lastError = null;
7112
+ for (let attempt = 0;attempt <= MAX_RETRIES; attempt++) {
7113
+ try {
7114
+ const response = await fetch(url, {
7115
+ ...options,
7116
+ headers: {
7117
+ "Content-Type": "application/json",
7118
+ Authorization: `Bearer ${token}`,
7119
+ ...options.headers
7120
+ },
7121
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
7122
+ });
7123
+ const json = await response.json();
7124
+ if (!response.ok || !json.success) {
7125
+ const error = new ApiError(response.status, json.error?.code ?? "UNKNOWN_ERROR", json.error?.message ?? "An unknown error occurred");
7126
+ if (isRetryable(response.status) && attempt < MAX_RETRIES) {
7127
+ lastError = error;
7128
+ await sleep(RETRY_DELAYS[attempt]);
7129
+ continue;
7130
+ }
7131
+ throw error;
7132
+ }
7133
+ return json.data;
7134
+ } catch (error) {
7135
+ if (error instanceof ApiError && !isRetryable(error.status)) {
7136
+ throw error;
7137
+ }
7138
+ if (attempt < MAX_RETRIES) {
7139
+ lastError = error instanceof Error ? error : new Error(String(error));
7140
+ await sleep(RETRY_DELAYS[attempt]);
7141
+ continue;
7142
+ }
7143
+ if (error instanceof Error) {
7144
+ if (error.name === "TimeoutError" || error.name === "AbortError") {
7145
+ throw new ApiError(0, "TIMEOUT", `Request timed out after ${REQUEST_TIMEOUT_MS}ms`);
7146
+ }
7147
+ throw new ApiError(0, "NETWORK_ERROR", error.message);
7148
+ }
7149
+ throw error;
7054
7150
  }
7055
- });
7056
- const json = await response.json();
7057
- if (!response.ok || !json.success) {
7058
- throw new ApiError(response.status, json.error?.code ?? "UNKNOWN_ERROR", json.error?.message ?? "An unknown error occurred");
7059
7151
  }
7060
- return json.data;
7152
+ throw lastError ?? new ApiError(0, "UNKNOWN_ERROR", "Request failed");
7061
7153
  }
7062
7154
  async function getUser(token) {
7063
7155
  const result = await request("/api/auth/me", token);
@@ -7067,12 +7159,20 @@ async function listEndpoints(token) {
7067
7159
  const result = await request("/api/endpoints", token);
7068
7160
  return result;
7069
7161
  }
7162
+ async function createEndpoint(token, input) {
7163
+ const result = await request("/api/endpoints", token, {
7164
+ method: "POST",
7165
+ body: JSON.stringify(input)
7166
+ });
7167
+ return result;
7168
+ }
7070
7169
  async function exchangeCliCode(sessionId, code) {
7071
7170
  const apiUrl = getApiUrl();
7072
7171
  const response = await fetch(`${apiUrl}/api/auth/cli-exchange`, {
7073
7172
  method: "POST",
7074
7173
  headers: { "Content-Type": "application/json" },
7075
- body: JSON.stringify({ sessionId, code })
7174
+ body: JSON.stringify({ sessionId, code }),
7175
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
7076
7176
  });
7077
7177
  const json = await response.json();
7078
7178
  if (!response.ok || !json.success) {
@@ -7080,6 +7180,22 @@ async function exchangeCliCode(sessionId, code) {
7080
7180
  }
7081
7181
  return json.data;
7082
7182
  }
7183
+ async function revokeCliToken(token) {
7184
+ const apiUrl = getApiUrl();
7185
+ try {
7186
+ const response = await fetch(`${apiUrl}/api/cli/revoke`, {
7187
+ method: "POST",
7188
+ headers: {
7189
+ "Content-Type": "application/json",
7190
+ Authorization: `Bearer ${token}`
7191
+ },
7192
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
7193
+ });
7194
+ if (!response.ok) {
7195
+ return;
7196
+ }
7197
+ } catch {}
7198
+ }
7083
7199
 
7084
7200
  // src/ui/logger.ts
7085
7201
  var import_picocolors = __toESM(require_picocolors(), 1);
@@ -8457,6 +8573,11 @@ async function loginCommand(options) {
8457
8573
  try {
8458
8574
  const user = await getUser(existingConfig.token);
8459
8575
  log.info(`Already logged in as ${user.email}`);
8576
+ const expiry = checkTokenExpiry(existingConfig);
8577
+ if (expiry === "expiring_soon") {
8578
+ const daysLeft = getDaysUntilExpiry(existingConfig);
8579
+ log.warning(`Your token expires in ${daysLeft} day${daysLeft === 1 ? "" : "s"}. Run: nbound login to refresh.`);
8580
+ }
8460
8581
  log.dim("Run: nbound logout to switch accounts");
8461
8582
  return;
8462
8583
  } catch {}
@@ -8507,12 +8628,13 @@ async function loginCommand(options) {
8507
8628
  const spinner = createSpinner("Verifying code...");
8508
8629
  spinner.start();
8509
8630
  try {
8510
- const { token, user } = await exchangeCliCode(sessionId, code);
8631
+ const { token, user, expiresAt } = await exchangeCliCode(sessionId, code);
8511
8632
  saveConfig({
8512
8633
  token,
8513
8634
  user: { id: user.id, email: user.email },
8514
8635
  apiUrl,
8515
- wsUrl: apiUrl.replace("https://", "wss://").replace("http://", "ws://") + "/ws"
8636
+ wsUrl: apiUrl.replace("https://", "wss://").replace("http://", "ws://") + "/ws",
8637
+ expiresAt
8516
8638
  });
8517
8639
  spinner.succeed(`Logged in as ${user.email}`);
8518
8640
  log.blank();
@@ -8533,6 +8655,12 @@ async function logoutCommand() {
8533
8655
  log.info("Not logged in");
8534
8656
  return;
8535
8657
  }
8658
+ const config = loadConfig();
8659
+ if (config?.token) {
8660
+ try {
8661
+ await revokeCliToken(config.token);
8662
+ } catch {}
8663
+ }
8536
8664
  deleteConfig();
8537
8665
  log.success("Logged out");
8538
8666
  log.dim(`Removed ${configPath}`);
@@ -12667,17 +12795,19 @@ function getBackoffDelay(attempt, baseMs = 1000, maxMs = 30000, jitter = true) {
12667
12795
  }
12668
12796
  // src/lib/ws-client.ts
12669
12797
  var PING_INTERVAL_MS = 30000;
12798
+ var PONG_TIMEOUT_MS = 1e4;
12670
12799
  var AUTH_TIMEOUT_MS = 1e4;
12671
12800
 
12672
12801
  class WebSocketClient {
12673
12802
  ws = null;
12674
12803
  options;
12675
12804
  pingInterval = null;
12805
+ pongTimeout = null;
12676
12806
  authTimeout = null;
12807
+ reconnectTimeout = null;
12677
12808
  reconnectAttempt = 0;
12678
- isConnected = false;
12809
+ state = "disconnected";
12679
12810
  shouldReconnect = true;
12680
- authenticated = false;
12681
12811
  userId = null;
12682
12812
  plan = null;
12683
12813
  constructor(options) {
@@ -12691,11 +12821,13 @@ class WebSocketClient {
12691
12821
  if (this.ws) {
12692
12822
  this.ws.removeAllListeners();
12693
12823
  this.ws.close();
12824
+ this.ws = null;
12694
12825
  }
12695
- this.authenticated = false;
12826
+ this.state = "connecting";
12696
12827
  this.ws = new wrapper_default(this.options.wsUrl);
12697
12828
  this.ws.on("open", () => {
12698
12829
  this.reconnectAttempt = 0;
12830
+ this.state = "authenticating";
12699
12831
  this.sendAuth();
12700
12832
  this.startAuthTimeout();
12701
12833
  });
@@ -12703,20 +12835,26 @@ class WebSocketClient {
12703
12835
  try {
12704
12836
  const message = JSON.parse(data.toString());
12705
12837
  this.handleMessage(message);
12706
- } catch {}
12838
+ } catch (err) {
12839
+ this.options.onError(new Error(`Failed to parse WebSocket message: ${err instanceof Error ? err.message : "Unknown error"}`));
12840
+ }
12707
12841
  });
12708
12842
  this.ws.on("close", (code, reason) => {
12843
+ const prevState = this.state;
12709
12844
  this.cleanup();
12710
12845
  const reasonStr = reason?.toString() || `Code ${code}`;
12711
- if (this.shouldReconnect && this.authenticated) {
12846
+ if (this.shouldReconnect && prevState === "subscribed") {
12712
12847
  this.scheduleReconnect();
12713
12848
  this.options.onDisconnected(reasonStr);
12714
- } else if (!this.authenticated && this.shouldReconnect) {
12715
- this.options.onError(new Error(`Connection closed: ${reasonStr}`));
12849
+ } else if (prevState === "authenticating" && this.shouldReconnect) {
12850
+ this.shouldReconnect = false;
12851
+ this.options.onError(new Error(`Connection closed during auth: ${reasonStr}`));
12852
+ } else if (this.shouldReconnect && prevState === "connecting") {
12853
+ this.scheduleReconnect();
12716
12854
  }
12717
12855
  });
12718
12856
  this.ws.on("error", (error) => {
12719
- if (!this.isConnected && this.shouldReconnect) {
12857
+ if (this.state !== "subscribed" && this.shouldReconnect) {
12720
12858
  this.scheduleReconnect();
12721
12859
  } else {
12722
12860
  this.options.onError(error);
@@ -12727,7 +12865,7 @@ class WebSocketClient {
12727
12865
  switch (message.type) {
12728
12866
  case "auth_success": {
12729
12867
  const payload = message.payload;
12730
- this.authenticated = true;
12868
+ this.state = "authenticated";
12731
12869
  this.userId = payload.user?.id ?? payload.userId ?? null;
12732
12870
  this.plan = payload.plan;
12733
12871
  this.clearAuthTimeout();
@@ -12744,7 +12882,7 @@ class WebSocketClient {
12744
12882
  }
12745
12883
  case "subscribed": {
12746
12884
  const payload = message.payload;
12747
- this.isConnected = true;
12885
+ this.state = "subscribed";
12748
12886
  this.options.onConnected(payload.endpointIds, payload.webhookUrls ?? {});
12749
12887
  break;
12750
12888
  }
@@ -12757,32 +12895,59 @@ class WebSocketClient {
12757
12895
  break;
12758
12896
  }
12759
12897
  case "pong":
12898
+ this.clearPongTimeout();
12760
12899
  break;
12761
12900
  case "error": {
12762
12901
  const payload = message.payload;
12763
12902
  this.options.onError(new Error(`Server error: ${payload.message}`));
12764
12903
  break;
12765
12904
  }
12905
+ case "token_revoked":
12906
+ case "session_revoked": {
12907
+ this.shouldReconnect = false;
12908
+ this.options.onError(new Error("Your CLI token has been revoked. Please run `nbound login` again."));
12909
+ this.disconnect();
12910
+ break;
12911
+ }
12912
+ case "plan_changed": {
12913
+ const payload = message.payload;
12914
+ this.plan = payload.plan;
12915
+ break;
12916
+ }
12917
+ case "endpoint_deleted": {
12918
+ this.shouldReconnect = false;
12919
+ this.options.onError(new Error("This endpoint has been deleted."));
12920
+ this.disconnect();
12921
+ break;
12922
+ }
12923
+ default: {
12924
+ const unknownType2 = message.type;
12925
+ if (unknownType2) {}
12926
+ }
12766
12927
  }
12767
12928
  }
12768
12929
  sendAuth() {
12769
- this.send({
12930
+ if (!this.send({
12770
12931
  type: "auth",
12771
12932
  payload: { token: this.options.token }
12772
- });
12933
+ })) {
12934
+ this.options.onError(new Error("Failed to send auth message"));
12935
+ }
12773
12936
  }
12774
12937
  sendForwardSubscribe() {
12775
- this.send({
12938
+ if (!this.send({
12776
12939
  type: "forward_subscribe",
12777
12940
  payload: {
12778
12941
  endpointIds: [this.options.endpointId],
12779
12942
  port: this.options.port,
12780
12943
  host: this.options.host
12781
12944
  }
12782
- });
12945
+ })) {
12946
+ this.options.onError(new Error("Failed to send subscribe message"));
12947
+ }
12783
12948
  }
12784
12949
  sendForwardResponse(requestId, response) {
12785
- this.send({
12950
+ return this.send({
12786
12951
  type: "forward_response",
12787
12952
  payload: {
12788
12953
  requestId,
@@ -12796,7 +12961,7 @@ class WebSocketClient {
12796
12961
  });
12797
12962
  }
12798
12963
  sendForwardError(requestId, error) {
12799
- this.send({
12964
+ return this.send({
12800
12965
  type: "forward_error",
12801
12966
  payload: {
12802
12967
  requestId,
@@ -12809,17 +12974,42 @@ class WebSocketClient {
12809
12974
  }
12810
12975
  send(message) {
12811
12976
  if (this.ws && this.ws.readyState === wrapper_default.OPEN) {
12812
- this.ws.send(JSON.stringify(message));
12977
+ try {
12978
+ this.ws.send(JSON.stringify(message));
12979
+ return true;
12980
+ } catch {
12981
+ return false;
12982
+ }
12813
12983
  }
12984
+ return false;
12814
12985
  }
12815
12986
  startPingInterval() {
12816
12987
  this.pingInterval = setInterval(() => {
12817
- this.send({ type: "ping" });
12988
+ if (this.send({ type: "ping" })) {
12989
+ this.startPongTimeout();
12990
+ }
12818
12991
  }, PING_INTERVAL_MS);
12819
12992
  }
12993
+ startPongTimeout() {
12994
+ this.clearPongTimeout();
12995
+ this.pongTimeout = setTimeout(() => {
12996
+ if (this.state === "subscribed" && this.shouldReconnect) {
12997
+ this.options.onError(new Error("Connection timeout - no pong received"));
12998
+ this.cleanup();
12999
+ this.scheduleReconnect();
13000
+ this.options.onDisconnected("Ping timeout");
13001
+ }
13002
+ }, PONG_TIMEOUT_MS);
13003
+ }
13004
+ clearPongTimeout() {
13005
+ if (this.pongTimeout) {
13006
+ clearTimeout(this.pongTimeout);
13007
+ this.pongTimeout = null;
13008
+ }
13009
+ }
12820
13010
  startAuthTimeout() {
12821
13011
  this.authTimeout = setTimeout(() => {
12822
- if (!this.authenticated) {
13012
+ if (this.state === "authenticating") {
12823
13013
  this.shouldReconnect = false;
12824
13014
  this.options.onError(new Error("Authentication timed out"));
12825
13015
  this.disconnect();
@@ -12833,18 +13023,28 @@ class WebSocketClient {
12833
13023
  }
12834
13024
  }
12835
13025
  cleanup() {
12836
- this.isConnected = false;
13026
+ this.state = "disconnected";
12837
13027
  this.clearAuthTimeout();
13028
+ this.clearPongTimeout();
13029
+ this.clearReconnectTimeout();
12838
13030
  if (this.pingInterval) {
12839
13031
  clearInterval(this.pingInterval);
12840
13032
  this.pingInterval = null;
12841
13033
  }
12842
13034
  }
13035
+ clearReconnectTimeout() {
13036
+ if (this.reconnectTimeout) {
13037
+ clearTimeout(this.reconnectTimeout);
13038
+ this.reconnectTimeout = null;
13039
+ }
13040
+ }
12843
13041
  scheduleReconnect() {
13042
+ this.clearReconnectTimeout();
12844
13043
  this.reconnectAttempt++;
12845
13044
  const delay = getBackoffDelay(this.reconnectAttempt, 1000, 30000, true);
12846
13045
  this.options.onReconnecting(this.reconnectAttempt);
12847
- setTimeout(() => {
13046
+ this.reconnectTimeout = setTimeout(() => {
13047
+ this.reconnectTimeout = null;
12848
13048
  if (this.shouldReconnect) {
12849
13049
  this.doConnect();
12850
13050
  }
@@ -12860,7 +13060,7 @@ class WebSocketClient {
12860
13060
  }
12861
13061
  }
12862
13062
  get connected() {
12863
- return this.isConnected;
13063
+ return this.state === "subscribed";
12864
13064
  }
12865
13065
  }
12866
13066
 
@@ -12907,7 +13107,6 @@ class MultiEndpointClient {
12907
13107
  const url = new URL(baseUrl);
12908
13108
  url.searchParams.set("endpointId", endpointId);
12909
13109
  url.searchParams.set("type", "cli");
12910
- url.searchParams.set("token", this.options.token);
12911
13110
  return url.toString();
12912
13111
  }
12913
13112
  handleEndpointConnected(endpointId, subscribedIds, urls) {
@@ -12967,6 +13166,7 @@ class MultiEndpointClient {
12967
13166
  }
12968
13167
 
12969
13168
  // src/lib/forwarder.ts
13169
+ import { posix } from "node:path";
12970
13170
  var HOP_BY_HOP_HEADERS = new Set([
12971
13171
  "connection",
12972
13172
  "keep-alive",
@@ -12978,10 +13178,39 @@ var HOP_BY_HOP_HEADERS = new Set([
12978
13178
  "upgrade",
12979
13179
  "host"
12980
13180
  ]);
13181
+ var ALLOWED_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
13182
+ var MAX_RESPONSE_SIZE = 1024 * 1024;
13183
+ function normalizePath(pathPrefix, requestPath) {
13184
+ const prefix = pathPrefix.startsWith("/") ? pathPrefix : "/" + pathPrefix;
13185
+ const normalizedPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
13186
+ const path2 = requestPath.startsWith("/") ? requestPath : "/" + requestPath;
13187
+ const combined = normalizedPrefix + path2;
13188
+ const normalized = posix.normalize(combined);
13189
+ const expectedRoot = normalizedPrefix || "/";
13190
+ if (!normalized.startsWith(expectedRoot) && normalized !== expectedRoot.slice(0, -1)) {
13191
+ if (!normalized.startsWith("/")) {
13192
+ return null;
13193
+ }
13194
+ }
13195
+ return normalized;
13196
+ }
12981
13197
  async function forwardRequest(request2, options) {
12982
13198
  const startTime = Date.now();
13199
+ if (!ALLOWED_HOSTS.has(options.host.toLowerCase())) {
13200
+ throw {
13201
+ code: "INVALID_HOST",
13202
+ message: `Host '${options.host}' is not allowed. Only localhost, 127.0.0.1, and ::1 are permitted.`
13203
+ };
13204
+ }
13205
+ const normalizedPath = normalizePath(options.pathPrefix, request2.path);
13206
+ if (normalizedPath === null) {
13207
+ throw {
13208
+ code: "INVALID_PATH",
13209
+ message: `Path traversal attempt detected in '${request2.path}'`
13210
+ };
13211
+ }
12983
13212
  const url = new URL(`http://${options.host}:${options.port}`);
12984
- url.pathname = options.pathPrefix + request2.path;
13213
+ url.pathname = normalizedPath;
12985
13214
  for (const [key, value] of Object.entries(request2.query)) {
12986
13215
  url.searchParams.set(key, value);
12987
13216
  }
@@ -12997,15 +13226,48 @@ async function forwardRequest(request2, options) {
12997
13226
  method: request2.method,
12998
13227
  headers,
12999
13228
  body: request2.body,
13000
- signal: AbortSignal.timeout(30000)
13229
+ signal: AbortSignal.timeout(30000),
13230
+ redirect: "manual"
13001
13231
  });
13232
+ const contentLength = response.headers.get("content-length");
13233
+ if (contentLength && parseInt(contentLength, 10) > MAX_RESPONSE_SIZE) {
13234
+ throw {
13235
+ code: "RESPONSE_TOO_LARGE",
13236
+ message: `Response size ${contentLength} bytes exceeds limit of ${MAX_RESPONSE_SIZE} bytes`
13237
+ };
13238
+ }
13002
13239
  const responseHeaders = {};
13003
13240
  response.headers.forEach((value, key) => {
13004
13241
  if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) {
13005
13242
  responseHeaders[key] = value;
13006
13243
  }
13007
13244
  });
13008
- const body = await response.text();
13245
+ const reader = response.body?.getReader();
13246
+ if (!reader) {
13247
+ return {
13248
+ status: response.status,
13249
+ headers: responseHeaders,
13250
+ body: null,
13251
+ timeMs: Date.now() - startTime
13252
+ };
13253
+ }
13254
+ const chunks = [];
13255
+ let totalSize = 0;
13256
+ while (true) {
13257
+ const { done, value } = await reader.read();
13258
+ if (done)
13259
+ break;
13260
+ totalSize += value.length;
13261
+ if (totalSize > MAX_RESPONSE_SIZE) {
13262
+ reader.cancel();
13263
+ throw {
13264
+ code: "RESPONSE_TOO_LARGE",
13265
+ message: `Response size exceeds limit of ${MAX_RESPONSE_SIZE} bytes`
13266
+ };
13267
+ }
13268
+ chunks.push(value);
13269
+ }
13270
+ const body = new TextDecoder().decode(chunks.length === 1 ? chunks[0] : concatUint8Arrays(chunks));
13009
13271
  return {
13010
13272
  status: response.status,
13011
13273
  headers: responseHeaders,
@@ -13013,7 +13275,9 @@ async function forwardRequest(request2, options) {
13013
13275
  timeMs: Date.now() - startTime
13014
13276
  };
13015
13277
  } catch (error) {
13016
- const timeMs = Date.now() - startTime;
13278
+ if (error && typeof error === "object" && "code" in error) {
13279
+ throw error;
13280
+ }
13017
13281
  if (error instanceof Error) {
13018
13282
  if (error.cause && typeof error.cause === "object" && "code" in error.cause) {
13019
13283
  const code = error.cause.code;
@@ -13037,6 +13301,16 @@ async function forwardRequest(request2, options) {
13037
13301
  };
13038
13302
  }
13039
13303
  }
13304
+ function concatUint8Arrays(arrays) {
13305
+ const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
13306
+ const result = new Uint8Array(totalLength);
13307
+ let offset = 0;
13308
+ for (const arr of arrays) {
13309
+ result.set(arr, offset);
13310
+ offset += arr.length;
13311
+ }
13312
+ return result;
13313
+ }
13040
13314
 
13041
13315
  // src/commands/connect.ts
13042
13316
  async function connectCommand(port, options) {
@@ -13254,6 +13528,57 @@ async function endpointsCommand(options) {
13254
13528
  }
13255
13529
  }
13256
13530
 
13531
+ // src/commands/create.ts
13532
+ async function createCommand2(nameArg, options) {
13533
+ const config = loadConfig();
13534
+ if (!config) {
13535
+ log.error("Not logged in");
13536
+ log.info("Run: nbound login");
13537
+ process.exit(2);
13538
+ }
13539
+ const name = nameArg || options.name;
13540
+ if (!name) {
13541
+ log.error("Endpoint name is required");
13542
+ log.info("Usage: nbound create <name>");
13543
+ log.info(" or: nbound create --name <name>");
13544
+ process.exit(4);
13545
+ }
13546
+ const spinner = createSpinner("Creating endpoint...");
13547
+ spinner.start();
13548
+ try {
13549
+ const endpoint = await createEndpoint(config.token, {
13550
+ name,
13551
+ description: options.description
13552
+ });
13553
+ spinner.succeed("Endpoint created");
13554
+ log.blank();
13555
+ log.bold(endpoint.name);
13556
+ log.blank();
13557
+ log.info(` URL: ${endpoint.url}`);
13558
+ log.info(` Slug: ${endpoint.slug}`);
13559
+ log.info(` ID: ${endpoint.id}`);
13560
+ log.blank();
13561
+ log.dim("Run 'nbound connect <port>' to start forwarding");
13562
+ log.blank();
13563
+ } catch (error) {
13564
+ if (error instanceof ApiError) {
13565
+ if (error.status === 401) {
13566
+ spinner.fail("Session expired");
13567
+ log.info("Run: nbound login");
13568
+ process.exit(2);
13569
+ }
13570
+ if (error.code === "PLAN_LIMIT") {
13571
+ spinner.fail("Endpoint limit reached");
13572
+ log.info("Upgrade at https://app.nbound.dev/settings/billing");
13573
+ process.exit(2);
13574
+ }
13575
+ spinner.fail(error.message);
13576
+ process.exit(1);
13577
+ }
13578
+ throw error;
13579
+ }
13580
+ }
13581
+
13257
13582
  // src/index.ts
13258
13583
  program.name("nbound").description("Forward webhooks to localhost for development").version("1.0.0");
13259
13584
  program.command("login").description("Authenticate with nbound").option("--token <token>", "Use an existing API token").action(loginCommand);
@@ -13261,4 +13586,5 @@ program.command("logout").description("Clear stored credentials").action(logoutC
13261
13586
  program.command("whoami").description("Show current authenticated user").action(whoamiCommand);
13262
13587
  program.command("connect <port>").description("Forward webhooks to localhost").option("-e, --endpoint <id>", "Specific endpoint ID or slug").option("-p, --path <path>", "Path prefix for localhost").option("-h, --host <host>", "Local hostname", "localhost").option("--no-color", "Disable colored output").option("--json", "Output as JSON lines for scripting").option("-q, --quiet", "Minimal output").action(connectCommand);
13263
13588
  program.command("endpoints").description("List your endpoints").option("--json", "Output as JSON").action(endpointsCommand);
13589
+ program.command("create [name]").description("Create a new endpoint").option("-n, --name <name>", "Endpoint name").option("-d, --description <description>", "Endpoint description").action(createCommand2);
13264
13590
  program.parse();