Compare commits

...

15 Commits

Author SHA1 Message Date
empty
906ac686a2 feat: 添加生产环境专用 docker-compose.prod.yml
- 使用预构建镜像 image: droid2api:latest
- 适用于离线部署场景
- 本地开发保留 build: .
2025-12-27 17:04:40 +08:00
empty
3670aceb4a fix: 修复 Dockerfile 构建问题
- Node 24 → Node 20(更稳定)
- --only=production → --omit=dev(新语法)
2025-12-27 17:00:07 +08:00
empty
0504029e47 feat: 添加共享网络配置支持独立部署
- 配置 api-network 外部网络
- 支持与 new-api 独立部署但可互通
- 添加 AUTH_ENABLED 和 API_KEYS 环境变量
2025-12-27 16:46:50 +08:00
empty
51e4b3a839 docs: 更新 API 认证配置说明
- 添加 new-api/one-api 接入场景说明
- 明确两层安全验证流程
2025-12-27 16:24:26 +08:00
empty
d1dc095cb1 feat: 添加请求认证中间件保护 API 端点
- 新增 auth-middleware.js 验证客户端 API Key
- 支持 Authorization: Bearer <key> 和 x-api-key 两种方式
- API Keys 只通过环境变量配置(安全最佳实践)
- 公开路径: /, /health, /status
- 可配置 /v1/models 是否需要认证
- 启动时输出认证状态日志

配置方式:
  AUTH_ENABLED=true
  API_KEYS=sk-key1,sk-key2

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-27 16:14:15 +08:00
Claude Code
17ddd815a9 chore: 将 Cloudflare Tunnel 设为可选服务
使用 Docker Compose profiles 功能,默认不启动 tunnel 服务
启用方式: docker compose --profile tunnel up -d

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 08:03:15 +00:00
empty
df1ac40d41 chore: 添加 .serena/ 到 .gitignore
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-27 15:53:42 +08:00
empty
cc4e4cea94 fix: 修正 system_prompt 中的名称为 Droid
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-27 15:49:32 +08:00
empty
8068475d6e fix: 恢复误修改的 user_agent 和 system_prompt 配置
CORS 修复时不慎修改了无关字段,现恢复原值:
- user_agent: factory-cli/0.40.2
- system_prompt: You are Droid...built by Factory

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 15:36:39 +08:00
empty
eef909c5dd feat: 实现可配置的 CORS 安全策略
- 添加 getCorsConfig() 函数支持灵活的 CORS 配置
- 支持三种模式:禁用 CORS、白名单、允许所有来源
- 环境变量可覆盖 config.json 配置 (CORS_ENABLED, CORS_ALLOW_ALL, CORS_ORIGINS)
- config.json 默认使用白名单模式,仅允许 localhost
- 动态验证 Origin 头,不在白名单的请求不设置 CORS 头
- 添加 Vary: Origin 头支持 CDN 缓存

安全改进:
- 生产环境默认 allow_all=false,避免 CORS 通配符
- 白名单模式下,未授权来源的请求会被浏览器拒绝

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 15:33:04 +08:00
empty
3dccbcfed1 feat: 添加全局错误处理机制
- 添加 unhandledRejection 处理器捕获未处理的 Promise rejection
- 添加 uncaughtException 处理器捕获未捕获的异常
- 添加 SIGTERM/SIGINT 信号处理实现优雅关闭
- 实现 gracefulShutdown 函数,给正在处理的请求3秒完成时间
- 错误信息经过 sanitizeForLog 脱敏处理
- 生产环境下隐藏堆栈信息

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 15:24:03 +08:00
empty
ed888edfc9 feat: 增强 Token 过期验证机制
- 添加 tokenExpiresAt 状态变量追踪实际过期时间
- saveTokens() 保存 expires_at 字段到文件
- loadAuthConfig() 启动时验证 token 是否过期
- shouldRefresh() 优先使用实际过期时间判断
- 提前 30 分钟刷新避免临界问题
- 修复 refreshApiKey() 中的代码缩进问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 15:22:01 +08:00
empty
42fc3f2cf3 feat: 集成 Cloudflare Tunnel 支持
- docker-compose.yml 添加 cloudflared 服务
- 更新 .env.example 和 README.md 添加配置说明
- 支持通过 Cloudflare Tunnel 进行安全部署
2025-12-27 15:14:12 +08:00
empty
a18e45ee78 feat: add refresh retry/timeout and tests 2025-12-27 15:07:54 +08:00
empty
5e01993120 feat: add SLS toggle and sanitize logs 2025-12-27 15:07:28 +08:00
18 changed files with 994 additions and 125 deletions

View File

@@ -6,16 +6,40 @@ FACTORY_API_KEY=your_factory_api_key_here
# 方式2使用refresh token自动刷新次优先级
DROID_REFRESH_KEY=your_refresh_token_here
# refresh token 请求超时与重试(可选)
DROID_REFRESH_TIMEOUT_MS=15000
DROID_REFRESH_RETRIES=2
DROID_REFRESH_RETRY_BASE_MS=500
# 阿里云日志服务配置
SLS_ENABLED=false
ALIYUN_ACCESS_KEY_ID=your_access_key_id
ALIYUN_ACCESS_KEY_SECRET=your_access_key_secret
ALIYUN_SLS_ENDPOINT=cn-hangzhou.log.aliyuncs.com
ALIYUN_SLS_PROJECT=your_project_name
ALIYUN_SLS_LOGSTORE=your_logstore_name
# 账号同步脚本配置 (sync-accounts.sh)
# Deploy Configuration (sync-accounts.sh)
SYNC_SERVER=user@your-server.com
SYNC_REMOTE_PATH=/opt/droid2api
DEPLOY_TYPE=docker-compose
DOCKER_SERVICE_NAME=droid2api
PM2_APP_NAME=droid2api
# Cloudflare Tunnel Configuration (Optional)
# Get token from: https://one.dash.cloudflare.com/ -> Networks -> Tunnels
TUNNEL_TOKEN=
# CORS Configuration (Optional, overrides config.json)
# CORS_ENABLED=true
# CORS_ALLOW_ALL=false
# CORS_ORIGINS=https://app1.com,https://app2.com
# API Authentication - Protect your API endpoints
# Recommended for production or when used as backend for new-api/one-api
#
# Security flow: User -> [new-api验证] -> [droid2api验证] -> Factory API
#
AUTH_ENABLED=false # Set to true to enable authentication
API_KEYS=sk-internal-secret-key # Internal key shared with new-api (comma-separated for multiple)
AUTH_PUBLIC_MODELS=true # Allow /v1/models without auth

3
.gitignore vendored
View File

@@ -4,4 +4,5 @@ node_modules/
.DS_Store
*.txt
AGENTS.md
accounts.json
accounts.json
.serena/

View File

@@ -1,5 +1,5 @@
# 使用官方 Node.js 运行时作为基础镜像
FROM node:24-alpine
FROM node:20-alpine
# 设置工作目录
WORKDIR /app
@@ -8,7 +8,7 @@ WORKDIR /app
COPY package*.json ./
# 安装项目依赖
RUN npm ci --only=production
RUN npm ci --omit=dev
# 复制项目文件
COPY . .

View File

@@ -285,6 +285,29 @@ Docker部署支持以下环境变量
- `PORT` - 服务端口默认3000
- `NODE_ENV` - 运行环境production/development
### Cloudflare Tunnel 部署 (推荐)
本项目支持通过 Cloudflare Tunnel 进行安全暴露,无需在服务器防火墙开放端口,即可享受 DDoS 防护和 SSL 加密。
1. **获取 Tunnel Token**
- 访问 [Cloudflare Zero Trust Dashboard](https://one.dash.cloudflare.com/)
- 进入 `Networks` -> `Tunnels` -> `Create a tunnel`
- 选择 `Cloudflared` 部署模式
- 在 Public Hostname 中设置你的域名,指向 `http://droid2api:3000`
2. **配置环境变量**
`.env` 或服务器环境变量中设置 Token
```bash
export TUNNEL_TOKEN="your_tunnel_token_here"
```
3. **启动服务**
```bash
docker-compose up -d
```
系统会自动启动 `cloudflared` 容器并建立安全隧道。
### Claude Code集成
#### 配置Claude Code使用droid2api

View File

@@ -1,8 +1,8 @@
import fs from 'fs';
import path from 'path';
import fetch from 'node-fetch';
import { logInfo, logDebug, logError } from './logger.js';
import { getNextProxyAgent } from './proxy-manager.js';
import { getRefreshConfig, requestRefreshToken } from './refresh-client.js';
/**
* Account Manager - 管理多个 OAuth 账号的选择、刷新和统计
@@ -21,6 +21,7 @@ const CLIENT_ID = 'client_01HNM792M5G5G1A2THWPXKFMXB';
class AccountManager {
constructor() {
this.accounts = [];
this.refreshLocks = new Map(); // 刷新锁,避免同一账号并发刷新
this.settings = {
algorithm: 'weighted', // 'weighted' or 'simple'
refresh_interval_hours: REFRESH_INTERVAL_HOURS,
@@ -174,12 +175,30 @@ class AccountManager {
const needsRefresh = !account.access_token || this._shouldRefresh(account);
if (needsRefresh) {
await this._refreshToken(account);
await this._refreshTokenWithLock(account);
}
return account.access_token;
}
/**
* 带锁刷新,避免同一账号并发刷新
*/
async _refreshTokenWithLock(account) {
const existing = this.refreshLocks.get(account.id);
if (existing) {
return existing;
}
const refreshPromise = this._refreshToken(account)
.finally(() => {
this.refreshLocks.delete(account.id);
});
this.refreshLocks.set(account.id, refreshPromise);
return refreshPromise;
}
/**
* 检查是否需要刷新 token
*/
@@ -198,32 +217,15 @@ class AccountManager {
logInfo(`AccountManager: 刷新账号 ${account.id} 的 token...`);
try {
const formData = new URLSearchParams();
formData.append('grant_type', 'refresh_token');
formData.append('refresh_token', account.refresh_token);
formData.append('client_id', CLIENT_ID);
const proxyAgentInfo = getNextProxyAgent(REFRESH_URL);
const fetchOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData.toString()
};
if (proxyAgentInfo?.agent) {
fetchOptions.agent = proxyAgentInfo.agent;
}
const response = await fetch(REFRESH_URL, fetchOptions);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`刷新失败: ${response.status} ${errorText}`);
}
const data = await response.json();
const refreshConfig = getRefreshConfig();
const data = await requestRefreshToken({
refreshUrl: REFRESH_URL,
refreshToken: account.refresh_token,
clientId: CLIENT_ID,
proxyAgentInfo,
...refreshConfig
});
// 更新账号信息
account.access_token = data.access_token;

146
auth-middleware.js Normal file
View File

@@ -0,0 +1,146 @@
/**
* 请求认证中间件
* 验证客户端请求的 API Key保护 API 端点
*/
import { getConfig } from './config.js';
import { logInfo, logError } from './logger.js';
// 不需要认证的路径(精确匹配)
const PUBLIC_PATHS = new Set([
'/',
'/health',
'/status'
]);
// 可配置是否公开的路径
const OPTIONAL_PUBLIC_PATHS = new Set([
'/v1/models'
]);
/**
* 获取认证配置
* API Keys 只从环境变量读取(安全考虑)
* enabled/public_models 可从 config.json 读取默认值,环境变量可覆盖
*/
export function getAuthConfig() {
const cfg = getConfig();
const configAuth = cfg.auth || {};
// 环境变量
const envEnabled = process.env.AUTH_ENABLED;
const envApiKeys = process.env.API_KEYS;
const envPublicModels = process.env.AUTH_PUBLIC_MODELS;
// 解析 enabled环境变量 > config.json
let enabled = configAuth.enabled ?? false;
if (envEnabled !== undefined) {
enabled = ['true', '1', 'yes'].includes(envEnabled.toLowerCase());
}
// API Keys 只从环境变量读取(敏感信息不应存储在配置文件中)
let apiKeys = [];
if (envApiKeys) {
apiKeys = envApiKeys.split(',').map(k => k.trim()).filter(k => k);
}
// 解析 public_models环境变量 > config.json
let publicModels = configAuth.public_models ?? true;
if (envPublicModels !== undefined) {
publicModels = ['true', '1', 'yes'].includes(envPublicModels.toLowerCase());
}
return {
enabled,
apiKeys: new Set(apiKeys),
publicModels
};
}
/**
* 从请求头中提取 API Key
* 支持: Authorization: Bearer <key> 或 x-api-key: <key>
*/
function extractApiKey(req) {
// 优先检查 Authorization header
const authHeader = req.headers.authorization;
if (authHeader) {
if (authHeader.startsWith('Bearer ')) {
return authHeader.slice(7).trim();
}
// 也支持直接传 key不带 Bearer 前缀)
return authHeader.trim();
}
// 其次检查 x-api-key header
const xApiKey = req.headers['x-api-key'];
if (xApiKey) {
return xApiKey.trim();
}
return null;
}
/**
* 认证中间件
*/
export function authMiddleware(req, res, next) {
const authConfig = getAuthConfig();
// 如果认证未启用,直接放行
if (!authConfig.enabled) {
return next();
}
// 检查是否是公开路径
if (PUBLIC_PATHS.has(req.path)) {
return next();
}
// 检查可选公开路径
if (authConfig.publicModels && OPTIONAL_PUBLIC_PATHS.has(req.path)) {
return next();
}
// 检查 API Keys 是否配置
if (authConfig.apiKeys.size === 0) {
logError('Auth enabled but no API keys configured');
return res.status(500).json({
error: {
message: 'Server configuration error: authentication enabled but no API keys configured',
type: 'server_error',
code: 'auth_not_configured'
}
});
}
// 提取并验证 API Key
const clientKey = extractApiKey(req);
if (!clientKey) {
logInfo(`Auth failed: No API key provided for ${req.method} ${req.path}`);
return res.status(401).json({
error: {
message: 'Missing API key. Please include your API key in the Authorization header using Bearer auth (Authorization: Bearer YOUR_API_KEY) or as x-api-key header.',
type: 'authentication_error',
code: 'missing_api_key'
}
});
}
if (!authConfig.apiKeys.has(clientKey)) {
logInfo(`Auth failed: Invalid API key for ${req.method} ${req.path}`);
return res.status(401).json({
error: {
message: 'Invalid API key provided.',
type: 'authentication_error',
code: 'invalid_api_key'
}
});
}
// 认证通过
next();
}
export default authMiddleware;

160
auth.js
View File

@@ -1,25 +1,28 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import fetch from 'node-fetch';
import { logDebug, logError, logInfo } from './logger.js';
import { getNextProxyAgent } from './proxy-manager.js';
import { getAccountManager, initializeAccountManager } from './account-manager.js';
import { getRefreshConfig, requestRefreshToken } from './refresh-client.js';
// State management for API key and refresh
let currentApiKey = null;
let currentRefreshToken = null;
let lastRefreshTime = null;
let tokenExpiresAt = null; // Token 过期时间戳 (ms)
let clientId = null;
let authSource = null; // 'env' or 'file' or 'factory_key' or 'client' or 'multi_account'
let authFilePath = null;
let factoryApiKey = null; // From FACTORY_API_KEY environment variable
let multiAccountMode = false; // 是否启用多账号模式
let lastSelectedAccountId = null; // 记录最后选择的账号ID用于结果回调
let refreshInFlight = null; // 刷新锁,避免并发刷新
const REFRESH_URL = 'https://api.workos.com/user_management/authenticate';
const REFRESH_INTERVAL_HOURS = 6; // Refresh every 6 hours
const TOKEN_VALID_HOURS = 8; // Token valid for 8 hours
const REFRESH_BUFFER_MS = 30 * 60 * 1000; // 提前 30 分钟刷新
/**
* Generate a ULID (Universally Unique Lexicographically Sortable Identifier)
@@ -122,9 +125,25 @@ function loadAuthConfig() {
authSource = 'file';
authFilePath = factoryAuthPath;
// Also load access_token if available
// Also load access_token if available and not expired
if (authData.access_token) {
currentApiKey = authData.access_token.trim();
const expiresAt = authData.expires_at ? new Date(authData.expires_at).getTime() : null;
const now = Date.now();
if (expiresAt && expiresAt > now + REFRESH_BUFFER_MS) {
// Token 还有效且距离过期超过30分钟
currentApiKey = authData.access_token.trim();
tokenExpiresAt = expiresAt;
lastRefreshTime = authData.last_updated ? new Date(authData.last_updated).getTime() : now;
logInfo(`Loaded valid token from file, expires at: ${new Date(expiresAt).toISOString()}`);
} else if (expiresAt) {
// Token 已过期或即将过期
logInfo(`Stored token expired or expiring soon (expires_at: ${authData.expires_at}), will refresh`);
} else {
// 没有过期时间记录,按旧逻辑处理
currentApiKey = authData.access_token.trim();
logInfo('Loaded token from file (no expiry info, will check on first use)');
}
}
return { type: 'refresh', value: authData.refresh_token.trim() };
@@ -148,76 +167,80 @@ async function refreshApiKey() {
throw new Error('No refresh token available');
}
if (!clientId) {
clientId = 'client_01HNM792M5G5G1A2THWPXKFMXB';
logDebug(`Using fixed client ID: ${clientId}`);
if (refreshInFlight) {
return refreshInFlight;
}
logInfo('Refreshing API key...');
refreshInFlight = (async () => {
if (!clientId) {
clientId = 'client_01HNM792M5G5G1A2THWPXKFMXB';
logDebug(`Using fixed client ID: ${clientId}`);
}
logInfo('Refreshing API key...');
try {
const proxyAgentInfo = getNextProxyAgent(REFRESH_URL);
const refreshConfig = getRefreshConfig();
const data = await requestRefreshToken({
refreshUrl: REFRESH_URL,
refreshToken: currentRefreshToken,
clientId,
proxyAgentInfo,
...refreshConfig
});
// Update tokens
currentApiKey = data.access_token;
currentRefreshToken = data.refresh_token;
lastRefreshTime = Date.now();
// 设置过期时间默认8小时
tokenExpiresAt = lastRefreshTime + TOKEN_VALID_HOURS * 60 * 60 * 1000;
// Log user info
if (data.user) {
logInfo(`Authenticated as: ${data.user.email} (${data.user.first_name} ${data.user.last_name})`);
logInfo(`User ID: ${data.user.id}`);
logInfo(`Organization ID: ${data.organization_id}`);
}
// Save tokens to file
saveTokens(data.access_token, data.refresh_token);
logInfo(`New Refresh-Key: ${currentRefreshToken}`);
logInfo(`Token expires at: ${new Date(tokenExpiresAt).toISOString()}`);
logInfo('API key refreshed successfully');
return data.access_token;
} catch (error) {
logError('Failed to refresh API key', error);
throw error;
}
})();
try {
// Create form data
const formData = new URLSearchParams();
formData.append('grant_type', 'refresh_token');
formData.append('refresh_token', currentRefreshToken);
formData.append('client_id', clientId);
const proxyAgentInfo = getNextProxyAgent(REFRESH_URL);
const fetchOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData.toString()
};
if (proxyAgentInfo?.agent) {
fetchOptions.agent = proxyAgentInfo.agent;
}
const response = await fetch(REFRESH_URL, fetchOptions);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to refresh token: ${response.status} ${errorText}`);
}
const data = await response.json();
// Update tokens
currentApiKey = data.access_token;
currentRefreshToken = data.refresh_token;
lastRefreshTime = Date.now();
// Log user info
if (data.user) {
logInfo(`Authenticated as: ${data.user.email} (${data.user.first_name} ${data.user.last_name})`);
logInfo(`User ID: ${data.user.id}`);
logInfo(`Organization ID: ${data.organization_id}`);
}
// Save tokens to file
saveTokens(data.access_token, data.refresh_token);
logInfo(`New Refresh-Key: ${currentRefreshToken}`);
logInfo('API key refreshed successfully');
return data.access_token;
} catch (error) {
logError('Failed to refresh API key', error);
throw error;
return await refreshInFlight;
} finally {
refreshInFlight = null;
}
}
/**
* Save tokens to appropriate file
* @param {string} accessToken - Access token to save
* @param {string} refreshToken - Refresh token to save
* @param {number} expiresInMs - Token validity duration in milliseconds (default: TOKEN_VALID_HOURS)
*/
function saveTokens(accessToken, refreshToken) {
function saveTokens(accessToken, refreshToken, expiresInMs = TOKEN_VALID_HOURS * 60 * 60 * 1000) {
try {
const now = Date.now();
const expiresAt = new Date(now + expiresInMs).toISOString();
const authData = {
access_token: accessToken,
refresh_token: refreshToken,
last_updated: new Date().toISOString()
expires_at: expiresAt,
last_updated: new Date(now).toISOString()
};
// Ensure directory exists
@@ -233,6 +256,7 @@ function saveTokens(accessToken, refreshToken) {
Object.assign(authData, existingData, {
access_token: accessToken,
refresh_token: refreshToken,
expires_at: expiresAt,
last_updated: authData.last_updated
});
} catch (error) {
@@ -249,14 +273,27 @@ function saveTokens(accessToken, refreshToken) {
}
/**
* Check if API key needs refresh (older than 6 hours)
* Check if API key needs refresh
* Uses actual expiration time if available, falls back to time-based check
*/
function shouldRefresh() {
const now = Date.now();
// 如果有过期时间使用过期时间判断提前30分钟刷新
if (tokenExpiresAt) {
const shouldRefreshByExpiry = now + REFRESH_BUFFER_MS >= tokenExpiresAt;
if (shouldRefreshByExpiry) {
logDebug(`Token expiring soon (expires_at: ${new Date(tokenExpiresAt).toISOString()})`);
}
return shouldRefreshByExpiry;
}
// 回退到基于刷新时间的判断
if (!lastRefreshTime) {
return true;
}
const hoursSinceRefresh = (Date.now() - lastRefreshTime) / (1000 * 60 * 60);
const hoursSinceRefresh = (now - lastRefreshTime) / (1000 * 60 * 60);
return hoursSinceRefresh >= REFRESH_INTERVAL_HOURS;
}
@@ -383,4 +420,3 @@ export function getAuthStatus() {
multiAccountMode: false
};
}

View File

@@ -91,3 +91,43 @@ export function getRedirectedModelId(modelId) {
}
return modelId;
}
/**
* 获取 CORS 配置
* 优先级: 环境变量 > config.json > 默认值
* @returns {Object} CORS 配置对象
*/
export function getCorsConfig() {
const cfg = getConfig();
const configCors = cfg.cors || {};
// 环境变量覆盖
const envAllowAll = process.env.CORS_ALLOW_ALL;
const envOrigins = process.env.CORS_ORIGINS;
// 解析 allow_all
let allowAll = configCors.allow_all ?? false;
if (envAllowAll !== undefined) {
allowAll = ['true', '1', 'yes'].includes(envAllowAll.toLowerCase());
}
// 解析 origins
let origins = configCors.origins || [];
if (envOrigins) {
origins = envOrigins.split(',').map(o => o.trim()).filter(o => o);
}
// 解析 enabled
let enabled = configCors.enabled ?? true;
if (process.env.CORS_ENABLED !== undefined) {
enabled = ['true', '1', 'yes'].includes(process.env.CORS_ENABLED.toLowerCase());
}
return {
enabled,
allowAll,
origins,
methods: configCors.methods || ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
headers: configCors.headers || ['Content-Type', 'Authorization', 'X-API-Key', 'anthropic-version']
};
}

View File

@@ -91,6 +91,19 @@
"provider": "google"
}
],
"cors": {
"enabled": true,
"allow_all": false,
"origins": [
"http://localhost:3000",
"http://localhost:5173",
"http://127.0.0.1:3000"
]
},
"auth": {
"enabled": false,
"public_models": true
},
"dev_mode": false,
"user_agent": "factory-cli/0.40.2",
"system_prompt": "You are Droid, an AI software engineering agent built by Factory.\n\n"

61
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,61 @@
# 生产环境 Docker Compose 配置
# 使用预构建镜像,适用于离线部署
#
# 部署步骤:
# 1. docker network create api-network (首次)
# 2. gunzip -c droid2api.tar.gz | docker load
# 3. docker compose -f docker-compose.prod.yml up -d
services:
droid2api:
image: droid2api:latest # 使用预构建镜像(非 build
container_name: droid2api
ports:
- "3001:3000"
environment:
# 认证配置(按优先级选择其一):
- FACTORY_API_KEY=${FACTORY_API_KEY}
- DROID_REFRESH_KEY=${DROID_REFRESH_KEY}
# 阿里云日志服务配置
- ALIYUN_ACCESS_KEY_ID=${ALIYUN_ACCESS_KEY_ID}
- ALIYUN_ACCESS_KEY_SECRET=${ALIYUN_ACCESS_KEY_SECRET}
- ALIYUN_SLS_ENDPOINT=${ALIYUN_SLS_ENDPOINT}
- ALIYUN_SLS_PROJECT=${ALIYUN_SLS_PROJECT}
- ALIYUN_SLS_LOGSTORE=${ALIYUN_SLS_LOGSTORE}
# API 认证中间件new-api 接入时启用)
- AUTH_ENABLED=${AUTH_ENABLED:-false}
- API_KEYS=${API_KEYS}
volumes:
- ./data:/app/data
- ./accounts.json:/app/accounts.json:ro
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- api-network
# Cloudflare Tunnel (可选)
# 启用方式: docker compose -f docker-compose.prod.yml --profile tunnel up -d
tunnel:
image: cloudflare/cloudflared:latest
container_name: droid2api_tunnel
restart: unless-stopped
command: tunnel run
environment:
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
depends_on:
- droid2api
profiles:
- tunnel
networks:
- api-network
# 共享网络 - 与 new-api 等服务互通
# 首次使用: docker network create api-network
networks:
api-network:
external: true

View File

@@ -17,6 +17,9 @@ services:
- ALIYUN_SLS_ENDPOINT=${ALIYUN_SLS_ENDPOINT}
- ALIYUN_SLS_PROJECT=${ALIYUN_SLS_PROJECT}
- ALIYUN_SLS_LOGSTORE=${ALIYUN_SLS_LOGSTORE}
# API 认证中间件
- AUTH_ENABLED=${AUTH_ENABLED:-false}
- API_KEYS=${API_KEYS}
volumes:
# 可选持久化auth.json以保存刷新的tokens
- ./data:/app/data
@@ -29,3 +32,28 @@ services:
timeout: 10s
retries: 3
start_period: 40s
networks:
- api-network
# Cloudflare Tunnel (可选)
# 启用方式: docker compose --profile tunnel up -d
# 需要在 .env 中设置 TUNNEL_TOKEN
tunnel:
image: cloudflare/cloudflared:latest
container_name: droid2api_tunnel
restart: unless-stopped
command: tunnel run
environment:
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
depends_on:
- droid2api
profiles:
- tunnel
networks:
- api-network
# 共享网络配置 - 支持与 new-api 等其他服务独立部署但可互通
# 首次使用需先创建网络: docker network create api-network
networks:
api-network:
external: true

87
log-sanitizer.js Normal file
View File

@@ -0,0 +1,87 @@
const REDACTION = '[REDACTED]';
const REDACT_KEY_RE = /(authorization|x-api-key|api[-_]?key|access_token|refresh_token|client_secret|private_key|set-cookie|cookie|password|secret)/i;
const EMAIL_KEY_RE = /email/i;
const IP_KEY_RE = /(^ip$|ip_address|remote_address|x-forwarded-for)/i;
function maskEmail(value) {
if (typeof value !== 'string') return value;
return value.replace(/([A-Za-z0-9._%+-])([A-Za-z0-9._%+-]*)(@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g, '$1***$3');
}
function maskIp(value) {
if (typeof value !== 'string') return value;
let masked = value.replace(/\b(\d{1,3}\.\d{1,3}\.\d{1,3})\.\d{1,3}\b/g, '$1.xxx');
masked = masked.replace(/\b([A-Fa-f0-9]{0,4}:){2,7}[A-Fa-f0-9]{0,4}\b/g, '****');
return masked;
}
function maskTokensInString(value) {
if (typeof value !== 'string') return value;
let masked = value.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+\b/g, 'Bearer ' + REDACTION);
masked = masked.replace(/\b(api_key|apikey|access_token|refresh_token|client_secret|password)=([^\s&]+)/gi, '$1=' + REDACTION);
return masked;
}
function sanitizeString(value) {
if (typeof value !== 'string') return value;
let masked = value;
masked = maskTokensInString(masked);
masked = maskEmail(masked);
masked = maskIp(masked);
return masked;
}
function sanitizeValue(value, key, seen) {
if (value === null || value === undefined) return value;
if (key && REDACT_KEY_RE.test(key)) {
return REDACTION;
}
if (typeof value === 'string') {
if (key && EMAIL_KEY_RE.test(key)) {
return maskEmail(value);
}
if (key && IP_KEY_RE.test(key)) {
return maskIp(value);
}
return sanitizeString(value);
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (Array.isArray(value)) {
return value.map(item => sanitizeValue(item, key, seen));
}
if (typeof value === 'object') {
return sanitizeObject(value, seen);
}
return value;
}
function sanitizeObject(value, seen) {
if (!value || typeof value !== 'object') return value;
if (!seen) seen = new WeakSet();
if (seen.has(value)) return '[Circular]';
seen.add(value);
const output = Array.isArray(value) ? [] : {};
for (const [key, val] of Object.entries(value)) {
output[key] = sanitizeValue(val, key, seen);
}
return output;
}
export function sanitizeForLog(value) {
return sanitizeValue(value, null, new WeakSet());
}
export function sanitizeLogMessage(message) {
if (typeof message !== 'string') return message;
return sanitizeString(message);
}

View File

@@ -1,28 +1,29 @@
import { isDevMode } from './config.js';
import { sanitizeForLog, sanitizeLogMessage } from './log-sanitizer.js';
export function logInfo(message, data = null) {
console.log(`[INFO] ${message}`);
console.log(`[INFO] ${sanitizeLogMessage(message)}`);
if (data && isDevMode()) {
console.log(JSON.stringify(data, null, 2));
console.log(JSON.stringify(sanitizeForLog(data), null, 2));
}
}
export function logDebug(message, data = null) {
if (isDevMode()) {
console.log(`[DEBUG] ${message}`);
console.log(`[DEBUG] ${sanitizeLogMessage(message)}`);
if (data) {
console.log(JSON.stringify(data, null, 2));
console.log(JSON.stringify(sanitizeForLog(data), null, 2));
}
}
}
export function logError(message, error = null) {
console.error(`[ERROR] ${message}`);
console.error(`[ERROR] ${sanitizeLogMessage(message)}`);
if (error) {
if (isDevMode()) {
console.error(error);
console.error(sanitizeForLog(error));
} else {
console.error(error.message || error);
console.error(sanitizeLogMessage(error.message || String(error)));
}
}
}
@@ -30,16 +31,16 @@ export function logError(message, error = null) {
export function logRequest(method, url, headers = null, body = null) {
if (isDevMode()) {
console.log(`\n${'='.repeat(80)}`);
console.log(`[REQUEST] ${method} ${url}`);
console.log(`[REQUEST] ${sanitizeLogMessage(method)} ${sanitizeLogMessage(url)}`);
if (headers) {
console.log('[HEADERS]', JSON.stringify(headers, null, 2));
console.log('[HEADERS]', JSON.stringify(sanitizeForLog(headers), null, 2));
}
if (body) {
console.log('[BODY]', JSON.stringify(body, null, 2));
console.log('[BODY]', JSON.stringify(sanitizeForLog(body), null, 2));
}
console.log('='.repeat(80) + '\n');
} else {
console.log(`[REQUEST] ${method} ${url}`);
console.log(`[REQUEST] ${sanitizeLogMessage(method)} ${sanitizeLogMessage(url)}`);
}
}
@@ -48,10 +49,10 @@ export function logResponse(status, headers = null, body = null) {
console.log(`\n${'-'.repeat(80)}`);
console.log(`[RESPONSE] Status: ${status}`);
if (headers) {
console.log('[HEADERS]', JSON.stringify(headers, null, 2));
console.log('[HEADERS]', JSON.stringify(sanitizeForLog(headers), null, 2));
}
if (body) {
console.log('[BODY]', JSON.stringify(body, null, 2));
console.log('[BODY]', JSON.stringify(sanitizeForLog(body), null, 2));
}
console.log('-'.repeat(80) + '\n');
} else {

View File

@@ -6,7 +6,8 @@
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node server.js"
"dev": "node server.js",
"test": "node --test"
},
"keywords": [
"openai",
@@ -21,4 +22,4 @@
"node-fetch": "^3.3.2",
"aliyun-log": "github:aliyun/aliyun-log-nodejs-sdk"
}
}
}

132
refresh-client.js Normal file
View File

@@ -0,0 +1,132 @@
import fetch from 'node-fetch';
const DEFAULT_TIMEOUT_MS = 15000;
const DEFAULT_MAX_RETRIES = 2;
const DEFAULT_RETRY_BASE_MS = 500;
const MAX_RETRY_DELAY_MS = 5000;
function normalizeNumber(value, fallback) {
const parsed = parseInt(value, 10);
if (Number.isFinite(parsed) && parsed >= 0) {
return parsed;
}
return fallback;
}
export function getRefreshConfig() {
return {
timeoutMs: normalizeNumber(process.env.DROID_REFRESH_TIMEOUT_MS, DEFAULT_TIMEOUT_MS),
maxRetries: normalizeNumber(process.env.DROID_REFRESH_RETRIES, DEFAULT_MAX_RETRIES),
retryDelayMs: normalizeNumber(process.env.DROID_REFRESH_RETRY_BASE_MS, DEFAULT_RETRY_BASE_MS)
};
}
function sleep(ms) {
if (!ms || ms <= 0) return Promise.resolve();
return new Promise(resolve => setTimeout(resolve, ms));
}
function isRetryableError(error) {
if (!error) return false;
if (error.name === 'AbortError') return true;
const retryCodes = new Set(['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EAI_AGAIN', 'ENOTFOUND']);
return retryCodes.has(error.code);
}
function shouldRetryStatus(status) {
return status === 429 || status >= 500;
}
function parseRetryAfterMs(response) {
if (!response?.headers?.get) return null;
const raw = response.headers.get('retry-after');
if (!raw) return null;
const seconds = parseInt(raw, 10);
if (Number.isFinite(seconds)) {
return Math.max(0, seconds * 1000);
}
const dateMs = Date.parse(raw);
if (!Number.isNaN(dateMs)) {
return Math.max(0, dateMs - Date.now());
}
return null;
}
async function fetchWithTimeout(url, options, timeoutMs, fetchImpl) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetchImpl(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
function buildError(status, errorText) {
const message = `Failed to refresh token: ${status} ${errorText || ''}`.trim();
const error = new Error(message);
error.status = status;
return error;
}
export async function requestRefreshToken(options) {
const {
refreshUrl,
refreshToken,
clientId,
proxyAgentInfo,
timeoutMs = DEFAULT_TIMEOUT_MS,
maxRetries = DEFAULT_MAX_RETRIES,
retryDelayMs = DEFAULT_RETRY_BASE_MS,
fetchImpl = fetch
} = options;
let attempt = 0;
while (true) {
try {
const formData = new URLSearchParams();
formData.append('grant_type', 'refresh_token');
formData.append('refresh_token', refreshToken);
formData.append('client_id', clientId);
const fetchOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData.toString()
};
if (proxyAgentInfo?.agent) {
fetchOptions.agent = proxyAgentInfo.agent;
}
const response = await fetchWithTimeout(refreshUrl, fetchOptions, timeoutMs, fetchImpl);
if (!response.ok) {
const errorText = await response.text().catch(() => '');
if (shouldRetryStatus(response.status) && attempt < maxRetries) {
const retryAfter = parseRetryAfterMs(response);
const delay = retryAfter ?? Math.min(retryDelayMs * (2 ** attempt), MAX_RETRY_DELAY_MS);
attempt += 1;
await sleep(delay);
continue;
}
throw buildError(response.status, errorText);
}
return await response.json();
} catch (error) {
if (isRetryableError(error) && attempt < maxRetries) {
const delay = Math.min(retryDelayMs * (2 ** attempt), MAX_RETRY_DELAY_MS);
attempt += 1;
await sleep(delay);
continue;
}
throw error;
}
}
}

168
server.js
View File

@@ -1,27 +1,156 @@
import express from 'express';
import { loadConfig, isDevMode, getPort } from './config.js';
import { loadConfig, isDevMode, getPort, getCorsConfig } from './config.js';
import { logInfo, logError } from './logger.js';
import router from './routes.js';
import { initializeAuth } from './auth.js';
import { initializeUserAgentUpdater } from './user-agent-updater.js';
import './sls-logger.js'; // 初始化阿里云日志服务
import { sanitizeForLog } from './log-sanitizer.js';
import { authMiddleware, getAuthConfig } from './auth-middleware.js';
// ============================================================================
// 全局错误处理 - 必须在应用启动前注册
// ============================================================================
let isShuttingDown = false;
/**
* 优雅关闭服务器
*/
function gracefulShutdown(reason, exitCode = 1) {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
console.error(`\n${'='.repeat(80)}`);
console.error(`🛑 Server shutting down: ${reason}`);
console.error(`${'='.repeat(80)}\n`);
// 给正在处理的请求一些时间完成
setTimeout(() => {
process.exit(exitCode);
}, 3000);
}
/**
* 处理未捕获的 Promise Rejection
*/
process.on('unhandledRejection', (reason, promise) => {
const errorInfo = {
type: 'unhandledRejection',
reason: reason instanceof Error ? reason.message : String(reason),
stack: reason instanceof Error ? reason.stack : undefined,
timestamp: new Date().toISOString()
};
console.error(`\n${'='.repeat(80)}`);
console.error('⚠️ Unhandled Promise Rejection');
console.error('='.repeat(80));
console.error(`Time: ${errorInfo.timestamp}`);
console.error(`Reason: ${errorInfo.reason}`);
if (errorInfo.stack && isDevMode()) {
console.error(`Stack: ${errorInfo.stack}`);
}
console.error('='.repeat(80) + '\n');
logError('Unhandled Promise Rejection', sanitizeForLog(errorInfo));
// 在生产环境中unhandledRejection 可能表示严重问题,考虑退出
// 但为了服务稳定性,这里只记录不退出
// 如需更严格的处理,可取消下面的注释:
// gracefulShutdown('Unhandled Promise Rejection', 1);
});
/**
* 处理未捕获的异常
*/
process.on('uncaughtException', (error) => {
const errorInfo = {
type: 'uncaughtException',
message: error.message,
stack: error.stack,
timestamp: new Date().toISOString()
};
console.error(`\n${'='.repeat(80)}`);
console.error('💥 Uncaught Exception');
console.error('='.repeat(80));
console.error(`Time: ${errorInfo.timestamp}`);
console.error(`Error: ${errorInfo.message}`);
if (errorInfo.stack) {
console.error(`Stack: ${errorInfo.stack}`);
}
console.error('='.repeat(80) + '\n');
logError('Uncaught Exception', sanitizeForLog(errorInfo));
// uncaughtException 后进程状态不确定,必须退出
gracefulShutdown('Uncaught Exception', 1);
});
/**
* 处理进程信号
*/
process.on('SIGTERM', () => {
logInfo('Received SIGTERM signal');
gracefulShutdown('SIGTERM', 0);
});
process.on('SIGINT', () => {
logInfo('Received SIGINT signal');
gracefulShutdown('SIGINT', 0);
});
// ============================================================================
const app = express();
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
/**
* CORS 中间件 - 根据配置动态处理跨域请求
*/
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, anthropic-version');
const corsConfig = getCorsConfig();
// 如果 CORS 完全禁用,直接跳过
if (!corsConfig.enabled) {
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
return next();
}
const origin = req.headers.origin;
// 设置允许的方法和头
res.header('Access-Control-Allow-Methods', corsConfig.methods.join(', '));
res.header('Access-Control-Allow-Headers', corsConfig.headers.join(', '));
if (corsConfig.allowAll) {
// 允许所有来源(仅用于开发环境)
res.header('Access-Control-Allow-Origin', '*');
} else if (origin && corsConfig.origins.length > 0) {
// 白名单模式:验证请求来源
if (corsConfig.origins.includes(origin)) {
res.header('Access-Control-Allow-Origin', origin);
res.header('Vary', 'Origin');
}
// 不在白名单中的请求不设置 CORS 头,浏览器会拒绝
}
// 如果没有配置 origins 且不是 allowAll则不设置任何 CORS 头
if (req.method === 'OPTIONS') {
return res.sendStatus(200);
return res.sendStatus(204);
}
next();
});
// 请求认证中间件
app.use(authMiddleware);
app.use(router);
app.get('/', (req, res) => {
@@ -58,6 +187,11 @@ app.use((req, res, next) => {
ip: req.ip || req.connection.remoteAddress
};
const safeQuery = sanitizeForLog(errorInfo.query);
const safeBody = sanitizeForLog(errorInfo.body);
const safeHeaders = sanitizeForLog(errorInfo.headers);
const safeIp = sanitizeForLog(errorInfo.ip);
console.error('\n' + '='.repeat(80));
console.error('❌ 非法请求地址');
console.error('='.repeat(80));
@@ -67,23 +201,23 @@ app.use((req, res, next) => {
console.error(`路径: ${errorInfo.path}`);
if (Object.keys(errorInfo.query).length > 0) {
console.error(`查询参数: ${JSON.stringify(errorInfo.query, null, 2)}`);
console.error(`查询参数: ${JSON.stringify(safeQuery, null, 2)}`);
}
if (errorInfo.body && Object.keys(errorInfo.body).length > 0) {
console.error(`请求体: ${JSON.stringify(errorInfo.body, null, 2)}`);
console.error(`请求体: ${JSON.stringify(safeBody, null, 2)}`);
}
console.error(`客户端IP: ${errorInfo.ip}`);
console.error(`User-Agent: ${errorInfo.headers['user-agent'] || 'N/A'}`);
console.error(`客户端IP: ${safeIp}`);
console.error(`User-Agent: ${safeHeaders['user-agent'] || 'N/A'}`);
if (errorInfo.headers.referer) {
console.error(`来源: ${errorInfo.headers.referer}`);
if (safeHeaders.referer) {
console.error(`来源: ${safeHeaders.referer}`);
}
console.error('='.repeat(80) + '\n');
logError('Invalid request path', errorInfo);
logError('Invalid request path', sanitizeForLog(errorInfo));
res.status(404).json({
error: 'Not Found',
@@ -113,6 +247,14 @@ app.use((err, req, res, next) => {
loadConfig();
logInfo('Configuration loaded successfully');
logInfo(`Dev mode: ${isDevMode()}`);
// Log auth status
const authConfig = getAuthConfig();
if (authConfig.enabled) {
logInfo(`Auth enabled with ${authConfig.apiKeys.size} API key(s)`);
} else {
logInfo('Auth disabled - API endpoints are publicly accessible');
}
// Initialize User-Agent version updater
initializeUserAgentUpdater();

View File

@@ -8,6 +8,7 @@
*/
import ALSClient from 'aliyun-log';
import { sanitizeForLog } from './log-sanitizer.js';
// SLS 配置
const SLS_CONFIG = {
@@ -18,8 +19,22 @@ const SLS_CONFIG = {
logstore: process.env.ALIYUN_SLS_LOGSTORE
};
function resolveSlsEnabled() {
const raw = process.env.SLS_ENABLED;
if (raw === undefined || raw === null || String(raw).trim() === '') {
return process.env.NODE_ENV !== 'production';
}
const value = String(raw).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(value)) return true;
if (['0', 'false', 'no', 'off'].includes(value)) return false;
return process.env.NODE_ENV !== 'production';
}
const SLS_ENABLED = resolveSlsEnabled();
// 检查配置是否完整
const isConfigured = Object.values(SLS_CONFIG).every(v => v);
const isConfigured = SLS_ENABLED && Object.values(SLS_CONFIG).every(v => v);
let client = null;
let logQueue = [];
@@ -28,6 +43,9 @@ const FLUSH_INTERVAL_MS = 5000;
// 初始化 SLS Client
function initClient() {
if (!SLS_ENABLED) {
return null;
}
if (!isConfigured) {
console.warn('[SLS] 阿里云日志服务未配置,日志将仅输出到控制台');
return null;
@@ -73,7 +91,7 @@ async function flushLogs() {
// 定时刷新
let flushTimer = null;
function startFlushTimer() {
if (flushTimer || !isConfigured) return;
if (!SLS_ENABLED || flushTimer || !isConfigured) return;
flushTimer = setInterval(flushLogs, FLUSH_INTERVAL_MS);
}
@@ -90,10 +108,11 @@ function startFlushTimer() {
* @param {string} [logData.error] - 错误信息
*/
export function logRequest(logData) {
const enrichedLog = {
if (!SLS_ENABLED) return;
const enrichedLog = sanitizeForLog({
timestamp: new Date().toISOString(),
...logData
};
});
// 始终输出到控制台
console.log('[SLS]', JSON.stringify(enrichedLog));
@@ -112,6 +131,7 @@ export function logRequest(logData) {
* 优雅关闭,刷新剩余日志
*/
export async function shutdown() {
if (!SLS_ENABLED) return;
if (flushTimer) {
clearInterval(flushTimer);
flushTimer = null;

View File

@@ -0,0 +1,112 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { requestRefreshToken } from '../refresh-client.js';
function mockResponse({ ok, status, jsonData, textData, headers }) {
const normalizedHeaders = {};
if (headers) {
for (const [key, value] of Object.entries(headers)) {
normalizedHeaders[key.toLowerCase()] = value;
}
}
return {
ok,
status,
json: async () => jsonData ?? {},
text: async () => textData ?? '',
headers: {
get: (name) => normalizedHeaders[name.toLowerCase()] ?? null
}
};
}
test('refresh retries on 500 then succeeds', async () => {
let calls = 0;
const fetchImpl = async () => {
calls += 1;
if (calls === 1) {
return mockResponse({ ok: false, status: 500, textData: 'boom' });
}
return mockResponse({
ok: true,
status: 200,
jsonData: { access_token: 'access', refresh_token: 'refresh' }
});
};
const data = await requestRefreshToken({
refreshUrl: 'https://example.com',
refreshToken: 'refresh_token',
clientId: 'client',
timeoutMs: 20,
maxRetries: 1,
retryDelayMs: 1,
fetchImpl
});
assert.equal(data.access_token, 'access');
assert.equal(calls, 2);
});
test('refresh does not retry on 400', async () => {
let calls = 0;
const fetchImpl = async () => {
calls += 1;
return mockResponse({ ok: false, status: 400, textData: 'bad request' });
};
await assert.rejects(
() => requestRefreshToken({
refreshUrl: 'https://example.com',
refreshToken: 'refresh_token',
clientId: 'client',
timeoutMs: 20,
maxRetries: 2,
retryDelayMs: 1,
fetchImpl
}),
(err) => err?.status === 400
);
assert.equal(calls, 1);
});
test('refresh retries on timeout abort', async () => {
let calls = 0;
const fetchImpl = async (url, options) => {
calls += 1;
return new Promise((resolve, reject) => {
if (!options?.signal) {
resolve(mockResponse({ ok: true, status: 200, jsonData: {} }));
return;
}
if (options.signal.aborted) {
const error = new Error('Aborted');
error.name = 'AbortError';
reject(error);
return;
}
options.signal.addEventListener('abort', () => {
const error = new Error('Aborted');
error.name = 'AbortError';
reject(error);
});
});
};
await assert.rejects(
() => requestRefreshToken({
refreshUrl: 'https://example.com',
refreshToken: 'refresh_token',
clientId: 'client',
timeoutMs: 5,
maxRetries: 1,
retryDelayMs: 1,
fetchImpl
}),
(err) => err?.name === 'AbortError'
);
assert.equal(calls, 2);
});