feat(server): add Chrome extension download endpoint

This commit is contained in:
YBF
2026-09-03 16:13:22 +08:00
parent 6abd09217e
commit b61918a365
12 changed files with 365 additions and 10 deletions
+6
View File
@@ -6,6 +6,12 @@ HOST=127.0.0.1
PORT=7878
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/trade_message_center
# Chrome 扩展下载签名。TMC_PACKAGE_VERSION 由 workspace 命令从根 package.json 注入。
OSS_BUCKET=sinanpilot-bucket
OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
# Mind authorization upstream. For local `pnpm dev`, point this at the Mind HTTP mock.
MIND_AUTH_BASE_URL=https://mind.example.com
# Bright API 的 CORS 白名单
+30 -2
View File
@@ -51,6 +51,10 @@ jobs:
MIND_PAGE_ORIGIN: http://127.0.0.1:7878
ONETALK_PLUGIN_ORIGINS: chrome-extension://ogdbffjakeeidblabkeakakdecfbcmlf
MIND_AUTH_TIMEOUT_MS: "3000"
OSS_BUCKET: sinanpilot-bucket
OSS_ENDPOINT: https://oss-cn-hangzhou.aliyuncs.com
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
VITE_BRIGHT_WEBSOCKET_URL: ${{ vars.VITE_BRIGHT_WEBSOCKET_URL }}
steps:
- name: Checkout
@@ -77,6 +81,7 @@ jobs:
version="$(node scripts/package-version.mjs)"
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
printf 'PACKAGE_VERSION=%s\n' "$version" >> "$GITHUB_ENV"
printf 'TMC_PACKAGE_VERSION=%s\n' "$version" >> "$GITHUB_ENV"
- name: Verify CI authorization environment
shell: bash
@@ -213,6 +218,11 @@ jobs:
HOST: 0.0.0.0
PORT: 7878
DATABASE_URL: ${{ secrets.DATABASE_URL }}
PACKAGE_VERSION: ${{ needs.quality.outputs.extension_version }}
OSS_BUCKET: sinanpilot-bucket
OSS_ENDPOINT: https://oss-cn-hangzhou.aliyuncs.com
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
MIND_AUTH_BASE_URL: ${{ vars.MIND_AUTH_BASE_URL }}
MIND_PAGE_ORIGIN: ${{ vars.MIND_PAGE_ORIGIN }}
ONETALK_PLUGIN_ORIGINS: chrome-extension://ogdbffjakeeidblabkeakakdecfbcmlf
@@ -258,6 +268,11 @@ jobs:
: "${HOST:?HOST is required}"
: "${PORT:?PORT is required}"
: "${DATABASE_URL:?DATABASE_URL is required}"
: "${PACKAGE_VERSION:?PACKAGE_VERSION is required}"
: "${OSS_BUCKET:?OSS_BUCKET is required}"
: "${OSS_ENDPOINT:?OSS_ENDPOINT is required}"
: "${OSS_ACCESS_KEY_ID:?OSS_ACCESS_KEY_ID is required}"
: "${OSS_ACCESS_KEY_SECRET:?OSS_ACCESS_KEY_SECRET is required}"
: "${MIND_AUTH_BASE_URL:?MIND_AUTH_BASE_URL is required}"
: "${MIND_PAGE_ORIGIN:?MIND_PAGE_ORIGIN is required}"
: "${ONETALK_PLUGIN_ORIGINS:?ONETALK_PLUGIN_ORIGINS is required}"
@@ -286,14 +301,20 @@ jobs:
build_state_path="$BUILD_PATH/.ci"
database_file="$build_state_path/database_url"
oss_access_key_id_file="$build_state_path/oss_access_key_id"
oss_access_key_secret_file="$build_state_path/oss_access_key_secret"
runtime_env_file="$build_state_path/runtime.env"
target_key_file="$build_state_path/target_key"
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
"umask 077; mkdir -p $(quote_for_shell "$build_state_path"); cat > $(quote_for_shell "$database_file")" <<< "$DATABASE_URL"
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
"umask 077; cat > $(quote_for_shell "$oss_access_key_id_file")" <<< "$OSS_ACCESS_KEY_ID"
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
"umask 077; cat > $(quote_for_shell "$oss_access_key_secret_file")" <<< "$OSS_ACCESS_KEY_SECRET"
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
"umask 077; cat > $(quote_for_shell "$target_key_file")" <<< "$DEPLOY_SSH_KEY"
build_env="BUILD_PATH=$(quote_for_shell "$BUILD_PATH") SOURCE_TAG=$(quote_for_shell "$SOURCE_TAG") IMAGE_NAME=$(quote_for_shell "$IMAGE_NAME") IMAGE_TAG=$(quote_for_shell "$IMAGE_TAG") HOST=$(quote_for_shell "$HOST") PORT=$(quote_for_shell "$PORT") MIND_AUTH_BASE_URL=$(quote_for_shell "$MIND_AUTH_BASE_URL") MIND_PAGE_ORIGIN=$(quote_for_shell "$MIND_PAGE_ORIGIN") ONETALK_PLUGIN_ORIGINS=$(quote_for_shell "$ONETALK_PLUGIN_ORIGINS") MIND_AUTH_TIMEOUT_MS=$(quote_for_shell "$MIND_AUTH_TIMEOUT_MS") DEPLOY_HOST=$(quote_for_shell "$DEPLOY_HOST") DEPLOY_PORT=$(quote_for_shell "${DEPLOY_PORT:-}") DEPLOY_USER=$(quote_for_shell "$DEPLOY_USER") DEPLOY_PATH=$(quote_for_shell "$DEPLOY_PATH") DEPLOY_CONTAINER_NAME=$(quote_for_shell "$DEPLOY_CONTAINER_NAME") DEPLOY_HOST_PORT=$(quote_for_shell "$DEPLOY_HOST_PORT") DATABASE_FILE=$(quote_for_shell "$database_file") RUNTIME_ENV_FILE=$(quote_for_shell "$runtime_env_file") TARGET_KEY_FILE=$(quote_for_shell "$target_key_file")"
build_env="BUILD_PATH=$(quote_for_shell "$BUILD_PATH") SOURCE_TAG=$(quote_for_shell "$SOURCE_TAG") IMAGE_NAME=$(quote_for_shell "$IMAGE_NAME") IMAGE_TAG=$(quote_for_shell "$IMAGE_TAG") HOST=$(quote_for_shell "$HOST") PORT=$(quote_for_shell "$PORT") PACKAGE_VERSION=$(quote_for_shell "$PACKAGE_VERSION") OSS_BUCKET=$(quote_for_shell "$OSS_BUCKET") OSS_ENDPOINT=$(quote_for_shell "$OSS_ENDPOINT") MIND_AUTH_BASE_URL=$(quote_for_shell "$MIND_AUTH_BASE_URL") MIND_PAGE_ORIGIN=$(quote_for_shell "$MIND_PAGE_ORIGIN") ONETALK_PLUGIN_ORIGINS=$(quote_for_shell "$ONETALK_PLUGIN_ORIGINS") MIND_AUTH_TIMEOUT_MS=$(quote_for_shell "$MIND_AUTH_TIMEOUT_MS") DEPLOY_HOST=$(quote_for_shell "$DEPLOY_HOST") DEPLOY_PORT=$(quote_for_shell "${DEPLOY_PORT:-}") DEPLOY_USER=$(quote_for_shell "$DEPLOY_USER") DEPLOY_PATH=$(quote_for_shell "$DEPLOY_PATH") DEPLOY_CONTAINER_NAME=$(quote_for_shell "$DEPLOY_CONTAINER_NAME") DEPLOY_HOST_PORT=$(quote_for_shell "$DEPLOY_HOST_PORT") DATABASE_FILE=$(quote_for_shell "$database_file") OSS_ACCESS_KEY_ID_FILE=$(quote_for_shell "$oss_access_key_id_file") OSS_ACCESS_KEY_SECRET_FILE=$(quote_for_shell "$oss_access_key_secret_file") RUNTIME_ENV_FILE=$(quote_for_shell "$runtime_env_file") TARGET_KEY_FILE=$(quote_for_shell "$target_key_file")"
ssh "${build_ssh_options[@]}" "$BUILD_USER@$BUILD_HOST" \
"$build_env bash -s" <<'BUILD_SCRIPT'
set -euo pipefail
@@ -312,7 +333,7 @@ jobs:
}
cleanup() {
rm -f "$DATABASE_FILE" "$RUNTIME_ENV_FILE" "$TARGET_KEY_FILE"
rm -f "$DATABASE_FILE" "$OSS_ACCESS_KEY_ID_FILE" "$OSS_ACCESS_KEY_SECRET_FILE" "$RUNTIME_ENV_FILE" "$TARGET_KEY_FILE"
}
trap cleanup EXIT
@@ -333,11 +354,18 @@ jobs:
"$BUILD_PATH"
database_url="$(<"$DATABASE_FILE")"
oss_access_key_id="$(<"$OSS_ACCESS_KEY_ID_FILE")"
oss_access_key_secret="$(<"$OSS_ACCESS_KEY_SECRET_FILE")"
{
write_env_value NODE_ENV production
write_env_value HOST "$HOST"
write_env_value PORT "$PORT"
write_env_value DATABASE_URL "$database_url"
write_env_value TMC_PACKAGE_VERSION "$PACKAGE_VERSION"
write_env_value OSS_BUCKET "$OSS_BUCKET"
write_env_value OSS_ENDPOINT "$OSS_ENDPOINT"
write_env_value OSS_ACCESS_KEY_ID "$oss_access_key_id"
write_env_value OSS_ACCESS_KEY_SECRET "$oss_access_key_secret"
write_env_value MIND_AUTH_BASE_URL "$MIND_AUTH_BASE_URL"
write_env_value MIND_PAGE_ORIGIN "$MIND_PAGE_ORIGIN"
write_env_value ONETALK_PLUGIN_ORIGINS "$ONETALK_PLUGIN_ORIGINS"
@@ -26,10 +26,12 @@ apps/
│ │ ├── migrate.ts
│ │ └── schema/index.ts
│ ├── http/
│ │ ├── chrome-extension-download.ts
│ │ ├── harness.ts
│ │ ├── health.ts
│ │ └── onetalk.ts
│ ├── oss/
│ │ ├── chrome-extension-download.ts
│ │ └── v1-signed-url.ts
│ ├── onetalk/
│ │ ├── index.ts
@@ -53,6 +55,7 @@ apps/
│ └── meta/_journal.json
└── test/
├── app.test.ts
├── chrome-extension-download.test.ts
└── oss-v1-signed-url.test.ts
```
@@ -80,7 +83,9 @@ OneTalk 业务服务、Bright 读取路由、独立历史 cursor 和 repository
- `websocket/registry.ts` 只管理已认证连接和 publish-after-commit 的 Mind 发布,不创建消息事实或 outbox。
- `http/onetalk.ts` 只做 Bright scope/授权/参数边界和响应映射;列表/详情/历史查询委派给 `read-service.ts`,不直接访问 Drizzle。
- `http/harness.ts` 只提供本地原生 HTML 联调页;页面通过 Bright HTTP/WS 访问服务,不复制 repository 或 Mind legacy 读取。
- `http/chrome-extension-download.ts` 只返回当前 workspace 版本的扩展临时下载链接;对象路径和 V1 签名委托给 `oss/chrome-extension-download.ts`,不代理 ZIP 或记录签名。缺少签名凭据时稳定返回 `503 extension_download_unavailable`
- `src/oss/v1-signed-url.ts` 只拥有无网络副作用的 OSS V1 GET 签名、URL 边界校验和 query 拼接;它不读取 `process.env`、不记录 secret、不增加路由或 OSS 网络调用。调用者未来接入时仍须通过 `src/config.ts` 映射环境变量。
- `src/oss/chrome-extension-download.ts` 只拥有扩展发布包的 OSS 对象路径与签名组合;目录和文件名使用完整版本但不添加 `v` 前缀。
- `onetalk/history.ts` 只拥有不透明、绑定账号/会话的 `(sentAtMs, messageId)` keyset cursor 编解码;同步 anchor 不得复用为读取 cursor。
- `onetalk/read-cursor.ts` 只拥有绑定账号/查询或时间窗/asOf/keyset 的不透明列表/历史 cursor 编解码;同步 anchor 不得复用为读取 cursor。`onetalk/history.ts` 仅服务既有同步历史形状。
- 配置只由 `src/config.ts` 在启动边界解析;业务模块不得直接读取 `process.env`
+21 -6
View File
@@ -35,11 +35,13 @@ server 开发入口和迁移命令按以下顺序读取文件,后面的文件
### 必填基础配置
| 变量 | 说明 |
| -------------- | --------------------------------------------------------------- |
| `HOST` | server 监听地址,例如 `127.0.0.1`。 |
| `PORT` | server 监听端口,必须是 `1-65535` 的整数,例如 `7878`。 |
| `DATABASE_URL` | Bright PostgreSQL 连接串;启动 server 和执行 migration 都需要。 |
| 变量 | 说明 |
| -------------------------------------------- | --------------------------------------------------------------- |
| `HOST` | server 监听地址,例如 `127.0.0.1`。 |
| `PORT` | server 监听端口,必须是 `1-65535` 的整数,例如 `7878`。 |
| `DATABASE_URL` | Bright PostgreSQL 连接串;启动 server 和执行 migration 都需要。 |
| `OSS_BUCKET``OSS_ENDPOINT` | Chrome 扩展发布包所在的 OSS Bucket 与 HTTPS Endpoint。 |
| `OSS_ACCESS_KEY_ID``OSS_ACCESS_KEY_SECRET` | 仅服务端使用的 OSS V1 临时下载链接签名凭据。 |
本地默认配置如下:
@@ -111,6 +113,19 @@ VITE_BRIGHT_WEBSOCKET_URL=ws://127.0.0.1:7878/ws/plugin
只有以 `VITE_` 开头的变量会暴露给扩展代码,数据库连接串和 binding 等服务端敏感配置不得使用该前缀。
### Chrome 扩展下载链接
`GET /api/downloads/chrome-extension` 返回当前 workspace 完整版本的临时 OSS 下载地址:
```json
{
"version": "0.6.1",
"downloadUrl": "https://sinanpilot-bucket.oss-cn-hangzhou.aliyuncs.com/chrome-extension/0.6.1/trade-message-center-chrome-extension-0.6.1.zip?..."
}
```
版本由根 `package.json``version` 经 CI 注入为 `TMC_PACKAGE_VERSION`,接口和发布目录都不使用 `v` 前缀。OSS 签名参数由服务端签名器计算;访问密钥只保存在服务端运行时环境中。未配置 OSS 访问密钥时,接口明确返回 `503 extension_download_unavailable`,不影响本地其它 server 功能。
## Workspace 版本
根目录 `package.json``version` 是 workspace 发布版本的唯一编辑点。根目录的 `dev``dev:extension``build``typecheck``test``format:check` 会自动先执行版本镜像同步,再检查一致性;修改根版本后可以直接运行这些命令,不会因为子包镜像尚未更新而中断。
@@ -212,7 +227,7 @@ OSS Bucket、Endpoint 和 Region 已直接写在 `.github/workflows/release_ci.y
- Secrets`OSS_ACCESS_KEY_ID``OSS_ACCESS_KEY_SECRET`
上传账号只需要目标 Bucket 对应版本目录的写权限。Git tag 仍用于触发发布和定位源码,但不会作为扩展发布版本;版本目录和文件名使用根 `package.json``version`。现有同版本覆盖语义保持不变。
上传账号只需要目标 Bucket 对应版本目录的写权限。Git tag 仍用于触发发布和定位源码,但不会作为扩展发布版本;版本目录和文件名使用根 `package.json``version`,均不添加 `v` 前缀。现有同版本覆盖语义保持不变。
## Server 数据库迁移
@@ -79,8 +79,27 @@ test("publishes the SHA artifact under the quality job version path", async () =
publish,
/destination="oss:\/\/\$\{OSS_BUCKET\}\/chrome-extension\/\$\{PACKAGE_VERSION\}\/\$\{archive_name\}"/u,
);
assert.doesNotMatch(publish, /chrome-extension\/v\$\{PACKAGE_VERSION\}/u);
assert.match(publish, /unzip -p "\$archive" manifest\.json/u);
assert.match(publish, /typeof manifest\.version !== "string"/u);
assert.match(publish, /\[\[ "\$manifest_version" != "\$PACKAGE_VERSION" \]\]/u);
assert.doesNotMatch(publish, /GITHUB_REF_NAME|release_version/u);
});
test("deploys the validated root version and OSS signer configuration to the server", async () => {
const deploy = blockFromHeading(await readWorkflow(), " deploy:");
assert.match(
deploy,
/PACKAGE_VERSION:\s+\$\{\{ needs\.quality\.outputs\.extension_version \}\}/u,
);
assert.match(deploy, /write_env_value TMC_PACKAGE_VERSION "\$PACKAGE_VERSION"/u);
for (const key of [
"OSS_BUCKET",
"OSS_ENDPOINT",
"OSS_ACCESS_KEY_ID",
"OSS_ACCESS_KEY_SECRET",
]) {
assert.match(deploy, new RegExp(`write_env_value ${key} `, "u"));
}
});
+2
View File
@@ -12,6 +12,7 @@ import {
createMindAuthorizationReader,
type MindAuthorizationDiagnosticsSink,
} from "./mind-authorization.ts";
import { installChromeExtensionDownloadRoute } from "./http/chrome-extension-download.ts";
import { installHealthRoute } from "./http/health.ts";
import { installOneTalkHarnessRoute } from "./http/harness.ts";
import { installOneTalkReadRoutes } from "./http/onetalk.ts";
@@ -86,6 +87,7 @@ export const createApp = (
createOneTalkReadService(createOneTalkReadRepository(database.db));
installHealthRoute(app);
installChromeExtensionDownloadRoute(app, config.chromeExtensionDownload);
installOneTalkHarnessRoute(app);
const oneTalkRegistry = installWebsocket(app, {
authorization,
+26
View File
@@ -1,10 +1,16 @@
// 服务端环境配置解析与校验
import {
validateChromeExtensionDownloadConfig,
type ChromeExtensionDownloadConfig,
} from "./oss/chrome-extension-download.ts";
export type ServerConfig = {
host: string;
port: number;
databaseUrl: string;
environment: ServerEnvironment;
chromeExtensionDownload?: ChromeExtensionDownloadConfig;
mindAuthorization?: MindAuthorizationConfig;
};
@@ -107,6 +113,25 @@ const parseMindAuthorization = (
};
};
const parseChromeExtensionDownload = (
environment: Record<string, string | undefined>,
): ChromeExtensionDownloadConfig | undefined => {
const accessKeyId = environment.OSS_ACCESS_KEY_ID?.trim();
const accessKeySecret = environment.OSS_ACCESS_KEY_SECRET?.trim();
if (!accessKeyId && !accessKeySecret) return undefined;
return validateChromeExtensionDownloadConfig({
packageVersion: readRequired(environment, "TMC_PACKAGE_VERSION"),
signing: {
bucket: readRequired(environment, "OSS_BUCKET"),
endpoint: readRequired(environment, "OSS_ENDPOINT"),
accessKeyId,
accessKeySecret,
},
});
};
/** 读取并校验服务端启动配置。 */
export const loadConfig = (environment: Record<string, string | undefined>): ServerConfig => {
const normalizedEnvironment = parseEnvironment(environment.NODE_ENV);
@@ -115,6 +140,7 @@ export const loadConfig = (environment: Record<string, string | undefined>): Ser
port: parsePort(readRequired(environment, "PORT")),
databaseUrl: readRequired(environment, "DATABASE_URL"),
environment: normalizedEnvironment,
chromeExtensionDownload: parseChromeExtensionDownload(environment),
mindAuthorization: parseMindAuthorization(
environment,
normalizedEnvironment === "development" || environment.NODE_ENV?.trim() === "test",
@@ -0,0 +1,40 @@
// 提供 Chrome 扩展下载链接 HTTP 接口
import type { FastifyInstance } from "fastify";
import {
createChromeExtensionDownloadUrl,
type ChromeExtensionDownloadConfig,
} from "../oss/chrome-extension-download.ts";
export const CHROME_EXTENSION_DOWNLOAD_ROUTE = "/api/downloads/chrome-extension";
export type ChromeExtensionDownloadResponse = {
version: string;
downloadUrl: string;
};
export type ChromeExtensionDownloadUnavailableResponse = {
error: {
code: "extension_download_unavailable";
};
};
/** 注册返回当前 Chrome 扩展临时下载链接的接口。 */
export const installChromeExtensionDownloadRoute = (
app: FastifyInstance,
config: ChromeExtensionDownloadConfig | undefined,
): void => {
app.get(CHROME_EXTENSION_DOWNLOAD_ROUTE, async (_request, reply) => {
if (!config) {
return reply.code(503).send({
error: { code: "extension_download_unavailable" },
} satisfies ChromeExtensionDownloadUnavailableResponse);
}
return {
version: config.packageVersion,
downloadUrl: createChromeExtensionDownloadUrl(config),
} satisfies ChromeExtensionDownloadResponse;
});
};
@@ -0,0 +1,72 @@
// 生成 Chrome 扩展发布包下载地址
import {
createOssV1SignedUrl,
validateOssV1SigningConfig,
type OssV1SignedUrlOptions,
type OssV1SigningConfig,
} from "./v1-signed-url.ts";
export type ChromeExtensionDownloadConfig = {
packageVersion: string;
signing: OssV1SigningConfig;
};
const PACKAGE_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
const MAX_PACKAGE_VERSION_COMPONENT = 65_535;
const fail = (message: string): never => {
throw new Error(message);
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const parsePackageVersion = (value: unknown): string => {
if (typeof value !== "string" || !PACKAGE_VERSION_PATTERN.test(value)) {
return fail("Invalid Chrome extension version: expected three numeric components");
}
const components = value.split(".").map(Number);
if (components.some((component) => component > MAX_PACKAGE_VERSION_COMPONENT)) {
return fail(
`Invalid Chrome extension version: components must be between 0 and ${MAX_PACKAGE_VERSION_COMPONENT}`,
);
}
if (components.every((component) => component === 0)) {
return fail("Invalid Chrome extension version: must not be 0.0.0");
}
return value;
};
/** 校验扩展发布包下载所需的版本与 OSS 签名配置。 */
export const validateChromeExtensionDownloadConfig = (
config: unknown,
): ChromeExtensionDownloadConfig => {
if (!isRecord(config)) return fail("Invalid Chrome extension download config");
return {
packageVersion: parsePackageVersion(config.packageVersion),
signing: validateOssV1SigningConfig(config.signing),
};
};
const archiveNameFor = (packageVersion: string): string =>
`trade-message-center-chrome-extension-${packageVersion}.zip`;
const objectUrlFor = (config: ChromeExtensionDownloadConfig): string => {
const endpoint = new URL(config.signing.endpoint);
const objectKey = `chrome-extension/${config.packageVersion}/${archiveNameFor(config.packageVersion)}`;
return `https://${config.signing.bucket}.${endpoint.host}/${objectKey}`;
};
/** 根据 workspace 版本生成扩展发布包的临时下载地址。 */
export const createChromeExtensionDownloadUrl = (
config: ChromeExtensionDownloadConfig,
options?: OssV1SignedUrlOptions,
): string => {
const validConfig = validateChromeExtensionDownloadConfig(config);
return createOssV1SignedUrl(objectUrlFor(validConfig), validConfig.signing, options);
};
+3 -2
View File
@@ -92,7 +92,8 @@ const parseEndpoint = (endpoint: string): OssEndpoint => {
return { hostname: url.hostname, port: url.port };
};
const validateConfig = (config: unknown): OssV1SigningConfig => {
/** 校验 OSS V1 签名所需的运行时配置。 */
export const validateOssV1SigningConfig = (config: unknown): OssV1SigningConfig => {
const bucket = requireConfigString(config, "bucket");
const endpoint = requireConfigString(config, "endpoint");
const accessKeyId = requireConfigString(config, "accessKeyId");
@@ -206,7 +207,7 @@ export const createOssV1SignedUrl = (
config: OssV1SigningConfig,
options?: OssV1SignedUrlOptions,
): string => {
const validConfig = validateConfig(config);
const validConfig = validateOssV1SigningConfig(config);
const endpoint = parseEndpoint(validConfig.endpoint);
const parsedObjectUrl = parseObjectUrl(objectUrl, validConfig.bucket, endpoint);
const objectKey = decodeObjectKey(parsedObjectUrl.pathname);
+91
View File
@@ -12,6 +12,15 @@ const testConfig = {
port: 3000,
databaseUrl: "postgres://test:test@localhost:5432/test",
environment: "non_development" as const,
chromeExtensionDownload: {
packageVersion: "1.2.3",
signing: {
bucket: "example-bucket",
endpoint: "https://oss.example.com",
accessKeyId: "test-access-key-id",
accessKeySecret: "test-only-secret-value",
},
},
};
const createDatabaseStub = (): {
@@ -42,12 +51,30 @@ test("requires strict production Mind authorization configuration", () => {
HOST: "127.0.0.1",
PORT: "3000",
DATABASE_URL: "postgres://test:test@localhost:5432/test",
TMC_PACKAGE_VERSION: "1.2.3",
OSS_BUCKET: "example-bucket",
OSS_ENDPOINT: "https://oss.example.com",
OSS_ACCESS_KEY_ID: "test-access-key-id",
OSS_ACCESS_KEY_SECRET: "test-only-secret-value",
NODE_ENV: "production",
MIND_AUTH_BASE_URL: "https://mind.example.com",
MIND_PAGE_ORIGIN: "https://mind.example.com",
ONETALK_PLUGIN_ORIGINS: "chrome-extension://extension-id",
};
assert.equal(loadConfig(base).mindAuthorization?.timeoutMs, 3000);
assert.equal(loadConfig(base).chromeExtensionDownload?.packageVersion, "1.2.3");
assert.throws(
() => loadConfig({ ...base, TMC_PACKAGE_VERSION: "1.2" }),
/Invalid Chrome extension version/,
);
assert.equal(
loadConfig({
...base,
OSS_ACCESS_KEY_ID: undefined,
OSS_ACCESS_KEY_SECRET: undefined,
}).chromeExtensionDownload,
undefined,
);
assert.throws(
() => loadConfig({ ...base, ONETALK_PLUGIN_ORIGINS: "chrome-extension://" }),
/Invalid ONETALK_PLUGIN_ORIGINS/,
@@ -67,6 +94,11 @@ test("allows loopback HTTP Mind authorization only in development and test", ()
HOST: "127.0.0.1",
PORT: "3000",
DATABASE_URL: "postgres://test:test@localhost:5432/test",
TMC_PACKAGE_VERSION: "1.2.3",
OSS_BUCKET: "example-bucket",
OSS_ENDPOINT: "https://oss.example.com",
OSS_ACCESS_KEY_ID: "test-access-key-id",
OSS_ACCESS_KEY_SECRET: "test-only-secret-value",
MIND_AUTH_BASE_URL: "http://127.0.0.1:8787",
MIND_PAGE_ORIGIN: "http://127.0.0.1:3000",
ONETALK_PLUGIN_ORIGINS: "chrome-extension://extension-id",
@@ -107,6 +139,11 @@ test("normalizes only NODE_ENV=development as the development environment", () =
HOST: "127.0.0.1",
PORT: "3000",
DATABASE_URL: "postgres://test:test@localhost:5432/test",
TMC_PACKAGE_VERSION: "1.2.3",
OSS_BUCKET: "example-bucket",
OSS_ENDPOINT: "https://oss.example.com",
OSS_ACCESS_KEY_ID: "test-access-key-id",
OSS_ACCESS_KEY_SECRET: "test-only-secret-value",
MIND_AUTH_BASE_URL: "https://mind.example.com",
MIND_PAGE_ORIGIN: "https://mind.example.com",
ONETALK_PLUGIN_ORIGINS: "chrome-extension://extension-id",
@@ -152,6 +189,11 @@ test("does not read the removed development authorization fixture fields", () =>
HOST: "127.0.0.1",
PORT: "3000",
DATABASE_URL: "postgres://test:test@localhost:5432/test",
TMC_PACKAGE_VERSION: "1.2.3",
OSS_BUCKET: "example-bucket",
OSS_ENDPOINT: "https://oss.example.com",
OSS_ACCESS_KEY_ID: "test-access-key-id",
OSS_ACCESS_KEY_SECRET: "test-only-secret-value",
NODE_ENV: "development",
MIND_AUTH_BASE_URL: "http://127.0.0.1:8787",
MIND_PAGE_ORIGIN: "http://127.0.0.1:3000",
@@ -184,6 +226,55 @@ test("serves health and closes injected database resources", async () => {
assert.equal(database.getCloseCount(), 1);
});
test("returns the configured whole-version Chrome extension download URL", async () => {
const database = createDatabaseStub();
const app = createApp(testConfig, { database: database.connection });
try {
await app.ready();
const response = await app.inject({
method: "GET",
url: "/api/downloads/chrome-extension",
});
const body = response.json() as { version: string; downloadUrl: string };
const downloadUrl = new URL(body.downloadUrl);
assert.equal(response.statusCode, 200);
assert.equal(body.version, "1.2.3");
assert.equal(
downloadUrl.pathname,
"/chrome-extension/1.2.3/trade-message-center-chrome-extension-1.2.3.zip",
);
assert.equal(downloadUrl.searchParams.get("OSSAccessKeyId"), "test-access-key-id");
assert.equal(body.downloadUrl.includes("test-only-secret-value"), false);
} finally {
await app.close();
}
});
test("returns a stable error when download signing is not configured", async () => {
const database = createDatabaseStub();
const app = createApp(
{ ...testConfig, chromeExtensionDownload: undefined },
{ database: database.connection },
);
try {
await app.ready();
const response = await app.inject({
method: "GET",
url: "/api/downloads/chrome-extension",
});
assert.equal(response.statusCode, 503);
assert.deepEqual(response.json(), {
error: { code: "extension_download_unavailable" },
});
} finally {
await app.close();
}
});
test("registers the websocket server without crashing the app", async () => {
const database = createDatabaseStub();
const app = createApp(testConfig, { database: database.connection });
@@ -0,0 +1,50 @@
// 验证 Chrome 扩展发布包下载地址
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import test from "node:test";
import {
createChromeExtensionDownloadUrl,
type ChromeExtensionDownloadConfig,
} from "../src/oss/chrome-extension-download.ts";
const FIXED_NOW_MS = 1_700_000_000_000;
const FIXED_EXPIRES = 1_700_001_800;
const TEST_CONFIG: ChromeExtensionDownloadConfig = {
packageVersion: "1.2.3",
signing: {
bucket: "example-bucket",
endpoint: "https://oss.example.com",
accessKeyId: "test-access-key-id",
accessKeySecret: "test-only-secret-value",
},
};
test("uses the whole package version without a v prefix in the OSS object path", () => {
const downloadUrl = createChromeExtensionDownloadUrl(TEST_CONFIG, { now: () => FIXED_NOW_MS });
const parsed = new URL(downloadUrl);
const objectKey = "chrome-extension/1.2.3/trade-message-center-chrome-extension-1.2.3.zip";
const expectedSignature = createHmac("sha1", TEST_CONFIG.signing.accessKeySecret)
.update(`GET\n\n\n${FIXED_EXPIRES}\n/${TEST_CONFIG.signing.bucket}/${objectKey}`, "utf8")
.digest("base64");
assert.equal(parsed.origin, "https://example-bucket.oss.example.com");
assert.equal(parsed.pathname, `/${objectKey}`);
assert.deepEqual(Array.from(parsed.searchParams.entries()), [
["Expires", FIXED_EXPIRES.toString()],
["OSSAccessKeyId", TEST_CONFIG.signing.accessKeyId],
["Signature", expectedSignature],
]);
});
test("rejects versions outside the shared package-version contract", () => {
assert.throws(
() =>
createChromeExtensionDownloadUrl({
...TEST_CONFIG,
packageVersion: "v1.2.3",
}),
/Invalid Chrome extension version/,
);
});