feat(update): secure signed plugin release sets
这个提交包含在:
父节点
3d2134e08a
当前提交
daa96302dd
@ -1,6 +1,6 @@
|
|||||||
# RN SDK 重构实施接管文档
|
# RN SDK 重构实施接管文档
|
||||||
|
|
||||||
> 状态更新时间:2026-07-26
|
> 状态更新时间:2026-07-27
|
||||||
> 当前实施范围:`@xuqm/rn-common`、`@xuqm/rn-bugcollect`、`@xuqm/rn-update`、`@xuqm/rn-xwebview`
|
> 当前实施范围:`@xuqm/rn-common`、`@xuqm/rn-bugcollect`、`@xuqm/rn-update`、`@xuqm/rn-xwebview`
|
||||||
> 发布约束:所有 XuqmGroup npm/Maven 制品和服务部署只能通过 `https://jenkins.xuqinmin.com/` 的 Jenkins 完成。
|
> 发布约束:所有 XuqmGroup npm/Maven 制品和服务部署只能通过 `https://jenkins.xuqinmin.com/` 的 Jenkins 完成。
|
||||||
|
|
||||||
@ -32,6 +32,37 @@
|
|||||||
补检一次并通过 `onAutomaticCheck` 交给宿主决定 UI。账号变化会取消旧下载授权和
|
补检一次并通过 `onAutomaticCheck` 交给宿主决定 UI。账号变化会取消旧下载授权和
|
||||||
灰度缓存;后台补检失败不影响宿主登录。服务端权威字段
|
灰度缓存;后台补检失败不影响宿主登录。服务端权威字段
|
||||||
`allowAnonymousUpdateCheck` 缺失时安全默认禁止匿名检查。
|
`allowAnonymousUpdateCheck` 缺失时安全默认禁止匿名检查。
|
||||||
|
- Update 服务开关统一解析 `features.update`(兼容平铺 `updateEnabled`),缺失安全
|
||||||
|
默认关闭并进入最后成功配置缓存。所有显式检查/下载/安装入口在网络和原生调用前
|
||||||
|
抛出 `UPDATE_DISABLED`;自动启动路径正常跳过。
|
||||||
|
- 插件候选已实现 Ed25519 不可变 manifest 验签:common 集中维护 canonical JSON、
|
||||||
|
可信 keyId 与 Hermes 运行时验签,Metro 构建期复用同一规则和密钥集合。候选在计划
|
||||||
|
前与安装前双重验签,Android stager 进一步绑定 buildId、bundleFormat 和
|
||||||
|
bundleSha256;响应 ZIP `sha256` 以 `archiveSha256` 纳入同一签名,下载后仍使用
|
||||||
|
同值校验归档。未知 keyId、签名错误和字段篡改测试均已覆盖。
|
||||||
|
common 加密、Ed25519 与 secureCache 复用 common/security 唯一纯 JS
|
||||||
|
Base64/Base64URL codec,不依赖 Hermes 全局 `atob/btoa`;非法字母表、长度、填充和
|
||||||
|
尾随位直接拒绝。canonical
|
||||||
|
JSON 对函数、symbol、非有限数、非普通对象、数组空位/undefined 确定性失败,避免
|
||||||
|
Node、Hermes 与服务端实现各自容错产生不同签名正文。
|
||||||
|
Server 的 `update-service/src/test/resources/rn-release-signature-vector.json` 是
|
||||||
|
唯一规范源。RN 测试只读明确标记 GENERATED/DO NOT EDIT 的仓内生成副本,保证独立
|
||||||
|
Git/Jenkins checkout 可运行;`scripts/sync-server-signature-vector.mjs
|
||||||
|
--check|--write` 负责跨仓字节级同步。相邻 Server 缺失时常规单仓 validate 跳过跨仓
|
||||||
|
比较,发布前必须提供 Server checkout 并执行 `--check`。测试会逐字节比较 canonical
|
||||||
|
UTF-8 正文,并以同一 keyId、原始公钥和 Base64URL 签名完成验签。
|
||||||
|
- release-set 检查已强制携带 appVersion、nativeApiLevel、nativeBaselineId;缺少
|
||||||
|
baseline 在发请求前失败。发布 Token 只读取 Jenkins 注入的 XUQM_API_TOKEN,
|
||||||
|
`release.apiToken` 配置会被构建校验拒绝。
|
||||||
|
- `xuqm.modules.json` 使用 `packageName` 保存 Android applicationId、使用
|
||||||
|
`iosBundleId` 保存 iOS Bundle Identifier;相应字段仅在目标平台
|
||||||
|
build/package/release 时必填。唯一 `resolveHostPackageId(config, platform)` 被
|
||||||
|
构建、在线配置验证、ZIP 期望身份和发布上传共同复用。wire 仍使用
|
||||||
|
`packageName`,但不会再把 Android 身份写入 iOS ZIP。
|
||||||
|
- 插件 ZIP v1 协议限制已与 Server 对齐:归档 50 MiB、单 entry 100 MiB、总解压
|
||||||
|
100 MiB、最多 4096 entries。Android stager 不再保留 5000 上限,4096/4097 和单
|
||||||
|
entry 100 MiB 边界已有原生单测;CLI `zipSync` 不生成目录 entry,Server 与 stager
|
||||||
|
均明确拒绝目录 entry。
|
||||||
- Debug 的 `XuqmRuntime.start()` 不做远程整包或插件检查;开发页仍可显式调用公开
|
- Debug 的 `XuqmRuntime.start()` 不做远程整包或插件检查;开发页仍可显式调用公开
|
||||||
check/install API 做一次性验证。
|
check/install API 做一次性验证。
|
||||||
- 插件构建清单已从 `xuqm.config.json` 更名为 `xuqm.modules.json`;租户平台签发的
|
- 插件构建清单已从 `xuqm.config.json` 更名为 `xuqm.modules.json`;租户平台签发的
|
||||||
@ -51,6 +82,11 @@
|
|||||||
- 整包和插件发布前调用 `/api/sdk/build/config/validate`。服务不可达、配置过期、
|
- 整包和插件发布前调用 `/api/sdk/build/config/validate`。服务不可达、配置过期、
|
||||||
revision 撤销或包名不匹配均阻断新 Release;已安装 App 运行时不查询吊销状态。
|
revision 撤销或包名不匹配均阻断新 Release;已安装 App 运行时不查询吊销状态。
|
||||||
- 相同 versionName 的连续 Android Release 自动生成递增 versionCode 和独立 buildId;
|
- 相同 versionName 的连续 Android Release 自动生成递增 versionCode 和独立 buildId;
|
||||||
|
- 2026-07-27 本地全量 `pnpm validate` 通过:六个 workspace TypeScript、common 42、
|
||||||
|
bugcollect 11、xwebview 7、IM 3、update CLI/Metro 28 与 update domain 18 项测试
|
||||||
|
全部通过;update 发布包内容校验为 51 个文件。common npm dry-run 未包含测试目录及
|
||||||
|
生成签名向量。Server 相邻仓库存在时签名向量逐字节检查通过,模拟 Server checkout
|
||||||
|
缺失的单仓 CI 也按契约独立通过。
|
||||||
同一工作区通过 `.xuqm-cache/release-build.json` 保证单调递增,跨工作区 CI 通过
|
同一工作区通过 `.xuqm-cache/release-build.json` 保证单调递增,跨工作区 CI 通过
|
||||||
`XUQM_VERSION_CODE` 和 `XUQM_BUILD_ID` 注入全局唯一流水线身份。
|
`XUQM_VERSION_CODE` 和 `XUQM_BUILD_ID` 注入全局唯一流水线身份。
|
||||||
- Gradle 内嵌任务把 `buildId` 与 `bugCollectMode` 声明为显式 `@Input` 并传入 embed
|
- Gradle 内嵌任务把 `buildId` 与 `bugCollectMode` 声明为显式 `@Input` 并传入 embed
|
||||||
@ -62,14 +98,15 @@
|
|||||||
- 原生最终恢复入口只删除 SDK 私有 `files/rn-bundles`,并递增 reset generation;
|
- 原生最终恢复入口只删除 SDK 私有 `files/rn-bundles`,并递增 reset generation;
|
||||||
common/app 初始化后仅清除 `xuqm_update_*` 状态并确认该 generation,重试不会误清
|
common/app 初始化后仅清除 `xuqm_update_*` 状态并确认该 generation,重试不会误清
|
||||||
宿主、BugCollect 或签名 SDK 数据。
|
宿主、BugCollect 或签名 SDK 数据。
|
||||||
- 已执行全部包类型检查、common 30 项、bugcollect 11 项、update CLI 25 项、update
|
- 已执行 `pnpm validate`:全部包类型检查、common 42 项、bugcollect 11 项、update
|
||||||
领域 13 项、xwebview 7 项测试、update 包内容校验、Prettier 和 `git diff --check`,
|
CLI 28 项、update 领域 18 项、xwebview 7 项和 IM 3 项测试均通过;update 包内容
|
||||||
均通过;update 发布包内容为 49 个文件。`pnpm validate` 本次因 Corepack/pnpm
|
校验通过,发布包为 51 个文件;Prettier 和 `git diff --check` 通过。
|
||||||
尝试联网读取 registry 且要求交互确认重建 `node_modules` 而未执行成功,已使用仓内
|
独立 `native-test` 的系统 Gradle 仍在任务配置前因本机
|
||||||
已安装工具逐项执行等价门禁。`native-test` 的 Gradle 在任务配置前因本机
|
`libnative-platform.dylib` 无法加载;改用 App4 Gradle 9.3.1 wrapper 后,
|
||||||
`libnative-platform.dylib` 无法加载而失败,不能据此声称本轮原生 Java 已编译。
|
`:xuqm_rn-update:testDebugUnitTest --tests
|
||||||
尚未执行宿主 Android 构建、设备运行、开发环境接口和真实 Source Map 上传;这些
|
com.xuqm.update.XuqmPluginPackageStagerTest` 共 25 tasks 构建并测试成功。
|
||||||
结果不得从静态检查推断。
|
尚未执行完整宿主 Android 构建、设备运行、开发环境接口和真实 Source Map 上传;
|
||||||
|
这些结果不得从当前门禁推断。
|
||||||
- License 离线激活 SDK 已审计:RNSDK 不存在对应包、API、依赖、脚本、文档或空目录;
|
- License 离线激活 SDK 已审计:RNSDK 不存在对应包、API、依赖、脚本、文档或空目录;
|
||||||
npm 的 `license: UNLICENSED` 元数据和 RN 插件事务 `activation` 不属于离线授权。
|
npm 的 `license: UNLICENSED` 元数据和 RN 插件事务 `activation` 不属于离线授权。
|
||||||
- 配置中的 `signingKey` 明确是客户端可提取的请求完整性/缓存派生材料,不是 License、
|
- 配置中的 `signingKey` 明确是客户端可提取的请求完整性/缓存派生材料,不是 License、
|
||||||
@ -156,7 +193,8 @@
|
|||||||
### 3.3 已确认缺口
|
### 3.3 已确认缺口
|
||||||
|
|
||||||
- RN update 已编码 `XuqmAppUpdateModule`,只桥接 XuqmGroup Android `sdk-update:2.0.0-SNAPSHOT`;Android Snapshot、Bridge 编译和 App4 完整 debug 构建已通过,模拟器安装事务仍待验证。
|
- RN update 已编码 `XuqmAppUpdateModule`,只桥接 XuqmGroup Android `sdk-update:2.0.0-SNAPSHOT`;Android Snapshot、Bridge 编译和 App4 完整 debug 构建已通过,模拟器安装事务仍待验证。
|
||||||
- release-set 已统一使用 SHA-256,但签名 manifest 尚待实现。
|
- release-set 已统一使用 SHA-256 和 Ed25519 签名 manifest;服务端仍需按同一
|
||||||
|
canonical 字段契约签发 `keyId/signature` 并完成联调。
|
||||||
- 原生 state 已成为 Bundle 版本与事务状态唯一真相;App 更新提示缓存仍可使用 AsyncStorage,但不保存 Bundle 版本。
|
- 原生 state 已成为 Bundle 版本与事务状态唯一真相;App 更新提示缓存仍可使用 AsyncStorage,但不保存 Bundle 版本。
|
||||||
- 客户端已改为 `/api/v1/rn/release-set/check` 单次请求目标入口依赖闭包;租户平台服务端接口尚待同步实现。
|
- 客户端已改为 `/api/v1/rn/release-set/check` 单次请求目标入口依赖闭包;租户平台服务端接口尚待同步实现。
|
||||||
- `nativeBaselineId` 已自动计算并进入内嵌 manifest/插件上传:覆盖 Android 原生源码、Gradle 配置、RN 与含原生代码的 npm 依赖,普通业务 JS 不影响指纹;Jenkins/服务端登记校验尚待实现。
|
- `nativeBaselineId` 已自动计算并进入内嵌 manifest/插件上传:覆盖 Android 原生源码、Gradle 配置、RN 与含原生代码的 npm 依赖,普通业务 JS 不影响指纹;Jenkins/服务端登记校验尚待实现。
|
||||||
|
|||||||
@ -67,6 +67,11 @@ await XuqmSDK.logout()
|
|||||||
|
|
||||||
## update 插件检查边界
|
## update 插件检查边界
|
||||||
|
|
||||||
|
Update 服务由平台远程配置 `features.update` 控制,缺失时默认关闭。显式调用
|
||||||
|
`checkAppUpdate`、`downloadApk`、`downloadAndInstallApp`、`checkPluginRelease` 或
|
||||||
|
`installPluginRelease` 时,关闭状态统一抛出 `UpdateDisabledError`,错误码为
|
||||||
|
`UPDATE_DISABLED`,且不会先访问网络或原生安装模块;自动启动检查会正常跳过。
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const plan = await UpdateSDK.checkPluginRelease('buz1', {
|
const plan = await UpdateSDK.checkPluginRelease('buz1', {
|
||||||
signal,
|
signal,
|
||||||
@ -87,4 +92,8 @@ await UpdateSDK.checkAndInstallPlugin('buz1', {
|
|||||||
- `checkPluginRelease()` 只检查版本和兼容依赖闭包,不下载、不激活;`timeoutMs` 只约束检查请求。
|
- `checkPluginRelease()` 只检查版本和兼容依赖闭包,不下载、不激活;`timeoutMs` 只约束检查请求。
|
||||||
- `installPluginRelease()` 只安装此前确认的计划;本地基线已变化时抛出 `StaleReleaseSetError`。
|
- `installPluginRelease()` 只安装此前确认的计划;本地基线已变化时抛出 `StaleReleaseSetError`。
|
||||||
- `checkAndInstallPlugin()` 用于宿主自动进入插件的场景;`checkTimeoutMs` 只传给检查阶段,下载仍由 `signal` 和进度回调控制。
|
- `checkAndInstallPlugin()` 用于宿主自动进入插件的场景;`checkTimeoutMs` 只传给检查阶段,下载仍由 `signal` 和进度回调控制。
|
||||||
|
- release-set 请求固定包含 `appVersion`、`nativeApiLevel`、`nativeBaselineId`;缺少
|
||||||
|
native baseline 时不发请求。服务端返回的插件候选必须具有平台 Ed25519
|
||||||
|
`keyId/signature`,客户端在计划前和安装前验证不可变 manifest。
|
||||||
|
候选响应的 ZIP `sha256` 以 `archiveSha256` 纳入签名,下载后继续以同一值校验归档。
|
||||||
- common HTTP 的统一默认请求上限为 30 秒;宿主需要更短的交互等待时显式传上述参数,不得在页面复制 `Promise.race` 或第二套超时器。
|
- common HTTP 的统一默认请求上限为 30 秒;宿主需要更短的交互等待时显式传上述参数,不得在页面复制 `Promise.race` 或第二套超时器。
|
||||||
|
|||||||
@ -11,6 +11,16 @@ pnpm xuqm:doctor
|
|||||||
|
|
||||||
`init` 只创建缺失的统一配置和必要脚本,不覆盖宿主已有代码。唯一插件构建清单为根目录 `xuqm.modules.json`。它与租户平台签发、宿主不可编辑的 `config.xuqmconfig` 初始化文件职责完全分离。
|
`init` 只创建缺失的统一配置和必要脚本,不覆盖宿主已有代码。唯一插件构建清单为根目录 `xuqm.modules.json`。它与租户平台签发、宿主不可编辑的 `config.xuqmconfig` 初始化文件职责完全分离。
|
||||||
|
|
||||||
|
构建清单分别使用 `packageName` 保存 Android applicationId、使用 `iosBundleId`
|
||||||
|
保存 iOS Bundle Identifier。目标平台字段只在该平台 build/package/release 时必填。
|
||||||
|
ZIP wire 字段统一名为 `packageName`,但其值必须由 CLI 按平台从上述两个配置字段解析;
|
||||||
|
发布时再与同一平台身份精确比较。
|
||||||
|
|
||||||
|
插件 ZIP v1 只有一套协议上限:归档不超过 50 MiB、单个 entry 解压后不超过
|
||||||
|
100 MiB、全部 entry 解压后合计不超过 100 MiB、entry 总数不超过 4096。CLI
|
||||||
|
`zipSync` 只生成文件 entry,因此显式目录 entry 不属于 v1 协议,服务端和 Android
|
||||||
|
stager 都必须拒绝;禁止在任一端另设 5000 等平行上限。
|
||||||
|
|
||||||
开发启动也使用同一 CLI,不由宿主复制 Node 包装脚本:
|
开发启动也使用同一 CLI,不由宿主复制 Node 包装脚本:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -18,12 +18,13 @@
|
|||||||
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
|
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "prettier --write AGENTS.md docs/IMPLEMENTATION_HANDOFF.md package.json packages/common packages/bugcollect packages/update packages/xwebview",
|
"format": "prettier --write AGENTS.md docs/IMPLEMENTATION_HANDOFF.md package.json scripts packages/common packages/bugcollect packages/update packages/xwebview",
|
||||||
"format:check": "prettier --check AGENTS.md docs/IMPLEMENTATION_HANDOFF.md package.json packages/common packages/bugcollect packages/update packages/xwebview",
|
"format:check": "prettier --check AGENTS.md docs/IMPLEMENTATION_HANDOFF.md package.json scripts packages/common packages/bugcollect packages/update packages/xwebview",
|
||||||
|
"signature-vector:check": "node scripts/sync-server-signature-vector.mjs --check",
|
||||||
"test": "corepack pnpm --recursive --if-present test",
|
"test": "corepack pnpm --recursive --if-present test",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"typecheck:all": "corepack pnpm --recursive --if-present typecheck",
|
"typecheck:all": "corepack pnpm --recursive --if-present typecheck",
|
||||||
"validate": "corepack pnpm format:check && corepack pnpm typecheck:all && corepack pnpm test"
|
"validate": "corepack pnpm signature-vector:check && corepack pnpm format:check && corepack pnpm typecheck:all && corepack pnpm test"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@react-native-async-storage/async-storage": ">=1.21.0",
|
"@react-native-async-storage/async-storage": ">=1.21.0",
|
||||||
|
|||||||
@ -45,6 +45,10 @@ common-only 项目不要放置 `.xuqmconfig`,也不要使用 `withXuqmConfig()
|
|||||||
|
|
||||||
Metro 预加载发起的自动初始化如果因临时网络或 DNS 问题失败,common 会终止该 Promise,避免形成未处理异常。随后第一个真正需要配置的扩展调用共享 `awaitInitialization()` 时,会使用同一份已解析配置重新尝试;配置、appKey 和平台地址不需要由页面或子 SDK 再次传入。持续失败由宿主入口统一记录一次,SDK 不重复打印同一错误。构建期配置损坏、密钥不匹配或 Schema 非法会直接阻断构建,禁止打出运行时才失败的安装包。
|
Metro 预加载发起的自动初始化如果因临时网络或 DNS 问题失败,common 会终止该 Promise,避免形成未处理异常。随后第一个真正需要配置的扩展调用共享 `awaitInitialization()` 时,会使用同一份已解析配置重新尝试;配置、appKey 和平台地址不需要由页面或子 SDK 再次传入。持续失败由宿主入口统一记录一次,SDK 不重复打印同一错误。构建期配置损坏、密钥不匹配或 Schema 非法会直接阻断构建,禁止打出运行时才失败的安装包。
|
||||||
|
|
||||||
|
平台远程配置中的 `features.update` 是 Update 服务开关;兼容字段
|
||||||
|
`updateEnabled` 只在前者缺失时读取。两者都缺失时安全默认关闭,最后成功配置缓存会
|
||||||
|
原样保存开关值。关闭 Update 不影响 common、宿主业务或其它扩展 SDK。
|
||||||
|
|
||||||
## 扩展包的唯一登录入口
|
## 扩展包的唯一登录入口
|
||||||
|
|
||||||
应用登录成功后只调用一次:
|
应用登录成功后只调用一次:
|
||||||
@ -78,3 +82,6 @@ await XuqmSDK.logout()
|
|||||||
- `getDeviceInfo`、`getDeviceId`、`useApi`、`usePageApi`、`showToast` 等项目通用能力。
|
- `getDeviceInfo`、`getDeviceId`、`useApi`、`usePageApi`、`showToast` 等项目通用能力。
|
||||||
|
|
||||||
业务代码不得导入 `@xuqm/rn-common/internal`。该子路径仅供官方 `@xuqm` 扩展包接入公共生命周期。
|
业务代码不得导入 `@xuqm/rn-common/internal`。该子路径仅供官方 `@xuqm` 扩展包接入公共生命周期。
|
||||||
|
`internal-security` 同样只供官方扩展使用:配置 V2 和插件不可变 manifest 复用一份
|
||||||
|
canonical JSON(键排序、无空白、null/undefined 字段省略)与可信 Ed25519 keyId
|
||||||
|
集合,不是宿主可替换的通用验签入口。
|
||||||
|
|||||||
@ -22,7 +22,9 @@ const CONFIG_DIRECTORY_RELATIVE_PATH = 'src/assets/config'
|
|||||||
const CONFIG_MAGIC = 'XUQM-CONFIG-V2'
|
const CONFIG_MAGIC = 'XUQM-CONFIG-V2'
|
||||||
const CONFIG_PASSPHRASE = 'xuqm-config-file-v2.2026.internal'
|
const CONFIG_PASSPHRASE = 'xuqm-config-file-v2.2026.internal'
|
||||||
const PBKDF2_ITERATIONS = 120_000
|
const PBKDF2_ITERATIONS = 120_000
|
||||||
const TRUSTED_CONFIG_KEYS = require('./trusted-config-keys.json')
|
const TRUSTED_CONFIG_KEYS = require('../security/trusted-signing-keys.json')
|
||||||
|
const { canonicalJson } = require('../security/canonical-json.js')
|
||||||
|
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
|
||||||
|
|
||||||
function decodeBase64Url(value) {
|
function decodeBase64Url(value) {
|
||||||
return Buffer.from(value, 'base64url')
|
return Buffer.from(value, 'base64url')
|
||||||
@ -32,18 +34,6 @@ function decodeBase64Url(value) {
|
|||||||
* 配置文件只在 Node/Metro 构建期解密。移动端运行时直接消费解析后的配置,
|
* 配置文件只在 Node/Metro 构建期解密。移动端运行时直接消费解析后的配置,
|
||||||
* 避免 Hermes 在应用启动阶段执行 12 万次 PBKDF2 并阻塞 JS 线程。
|
* 避免 Hermes 在应用启动阶段执行 12 万次 PBKDF2 并阻塞 JS 线程。
|
||||||
*/
|
*/
|
||||||
function canonicalJson(value) {
|
|
||||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
|
||||||
if (value && typeof value === 'object') {
|
|
||||||
return `{${Object.keys(value)
|
|
||||||
.filter(key => value[key] !== null && value[key] !== undefined)
|
|
||||||
.sort()
|
|
||||||
.map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
||||||
.join(',')}}`
|
|
||||||
}
|
|
||||||
return JSON.stringify(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertConfigPayload(config, plaintext) {
|
function assertConfigPayload(config, plaintext) {
|
||||||
const allowedFields = new Set([
|
const allowedFields = new Set([
|
||||||
'schemaVersion',
|
'schemaVersion',
|
||||||
@ -107,7 +97,7 @@ function decryptConfigAtBuildTime(content, trustedKeys = TRUSTED_CONFIG_KEYS) {
|
|||||||
null,
|
null,
|
||||||
Buffer.from(unsignedToken, 'ascii'),
|
Buffer.from(unsignedToken, 'ascii'),
|
||||||
{
|
{
|
||||||
key: Buffer.from(publicKeyBase64, 'base64'),
|
key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(publicKeyBase64, 'base64')]),
|
||||||
format: 'der',
|
format: 'der',
|
||||||
type: 'spki',
|
type: 'spki',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"xuqm-config-dev-2026-07": "MCowBQYDK2VwAyEAFt9MBN8jNwCfalex4wg7kuy2DRAbXkGxZyA/Jaz+6YA="
|
|
||||||
}
|
|
||||||
@ -13,6 +13,7 @@
|
|||||||
"./download": "./src/download.ts",
|
"./download": "./src/download.ts",
|
||||||
"./file-name": "./src/fileName.ts",
|
"./file-name": "./src/fileName.ts",
|
||||||
"./internal": "./src/internal.ts",
|
"./internal": "./src/internal.ts",
|
||||||
|
"./internal-security": "./src/internalSecurity.ts",
|
||||||
"./metro": "./metro/index.js",
|
"./metro": "./metro/index.js",
|
||||||
"./package.json": "./package.json",
|
"./package.json": "./package.json",
|
||||||
"./secure-cache": "./src/secureCache.ts",
|
"./secure-cache": "./src/secureCache.ts",
|
||||||
@ -21,7 +22,8 @@
|
|||||||
"metro": "metro/index.js",
|
"metro": "metro/index.js",
|
||||||
"files": [
|
"files": [
|
||||||
"src",
|
"src",
|
||||||
"metro"
|
"metro",
|
||||||
|
"security"
|
||||||
],
|
],
|
||||||
"private": false,
|
"private": false,
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
@ -33,6 +35,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/ciphers": "2.2.0",
|
"@noble/ciphers": "2.2.0",
|
||||||
|
"@noble/curves": "2.2.0",
|
||||||
"@noble/hashes": "2.2.0",
|
"@noble/hashes": "2.2.0",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
"react-native-blob-util": "0.24.10",
|
"react-native-blob-util": "0.24.10",
|
||||||
@ -40,18 +43,18 @@
|
|||||||
"zod": "4.4.3"
|
"zod": "4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=18.0.0",
|
|
||||||
"react-native": ">=0.76.0",
|
|
||||||
"@react-native-async-storage/async-storage": ">=1.21.0",
|
"@react-native-async-storage/async-storage": ">=1.21.0",
|
||||||
"axios": ">=1.0.0"
|
"axios": ">=1.0.0",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"react-native": ">=0.76.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "catalog:",
|
|
||||||
"@types/react": "^19.2.14",
|
|
||||||
"@react-native-async-storage/async-storage": "^3.1.1",
|
"@react-native-async-storage/async-storage": "^3.1.1",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
"axios": "^1.18.0",
|
"axios": "^1.18.0",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-native": "0.86.0",
|
"react-native": "0.86.0",
|
||||||
"tsx": "^4.23.1"
|
"tsx": "^4.23.1",
|
||||||
|
"typescript": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
packages/common/security/base64.d.ts
vendored
普通文件
5
packages/common/security/base64.d.ts
vendored
普通文件
@ -0,0 +1,5 @@
|
|||||||
|
export function decodeBase64(value: string): Uint8Array
|
||||||
|
export function encodeBase64(
|
||||||
|
value: Uint8Array,
|
||||||
|
options?: { urlSafe?: boolean; padding?: boolean },
|
||||||
|
): string
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
const STANDARD_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||||
|
const URL_SAFE_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 纯 JS Base64 编码,默认输出带填充的标准 Base64。
|
||||||
|
* 协议调用方必须显式声明 URL-safe 与是否保留填充,避免现场字符串替换。
|
||||||
|
*/
|
||||||
|
function encodeBase64(value, options = {}) {
|
||||||
|
if (!(value instanceof Uint8Array)) throw new TypeError('Base64 value must be Uint8Array')
|
||||||
|
const alphabet = options.urlSafe ? URL_SAFE_ALPHABET : STANDARD_ALPHABET
|
||||||
|
const padding = options.padding !== false
|
||||||
|
let output = ''
|
||||||
|
for (let offset = 0; offset < value.length; offset += 3) {
|
||||||
|
const first = value[offset]
|
||||||
|
const hasSecond = offset + 1 < value.length
|
||||||
|
const hasThird = offset + 2 < value.length
|
||||||
|
const second = hasSecond ? value[offset + 1] : 0
|
||||||
|
const third = hasThird ? value[offset + 2] : 0
|
||||||
|
output += alphabet[first >>> 2]
|
||||||
|
output += alphabet[((first & 0x03) << 4) | (second >>> 4)]
|
||||||
|
if (hasSecond) {
|
||||||
|
output += alphabet[((second & 0x0f) << 2) | (third >>> 6)]
|
||||||
|
} else if (padding) {
|
||||||
|
output += '='
|
||||||
|
}
|
||||||
|
if (hasThird) {
|
||||||
|
output += alphabet[third & 0x3f]
|
||||||
|
} else if (padding) {
|
||||||
|
output += '='
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 严格解码标准 Base64 或 Base64URL,不依赖浏览器/Hermes 的 atob。
|
||||||
|
* 接受有填充或无填充形式,但拒绝混合字母表、非法长度、非法填充和非零尾随位。
|
||||||
|
*/
|
||||||
|
function decodeBase64(value) {
|
||||||
|
if (typeof value !== 'string') throw new TypeError('Base64 value must be a string')
|
||||||
|
if (value.length === 0) return new Uint8Array(0)
|
||||||
|
if (/[^A-Za-z0-9+/_=-]/.test(value)) throw new Error('Base64 contains invalid characters')
|
||||||
|
if (/[+\/]/.test(value) && /[-_]/.test(value)) {
|
||||||
|
throw new Error('Base64 cannot mix standard and URL-safe alphabets')
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstPadding = value.indexOf('=')
|
||||||
|
const data = firstPadding === -1 ? value : value.slice(0, firstPadding)
|
||||||
|
const padding = firstPadding === -1 ? '' : value.slice(firstPadding)
|
||||||
|
if (padding !== '' && padding !== '=' && padding !== '==') {
|
||||||
|
throw new Error('Base64 padding is invalid')
|
||||||
|
}
|
||||||
|
if (data.includes('=')) throw new Error('Base64 padding is invalid')
|
||||||
|
const remainder = data.length % 4
|
||||||
|
if (remainder === 1) throw new Error('Base64 length is invalid')
|
||||||
|
const expectedPadding = remainder === 0 ? 0 : 4 - remainder
|
||||||
|
if (padding.length !== 0 && padding.length !== expectedPadding) {
|
||||||
|
throw new Error('Base64 padding length is invalid')
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = data.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const outputLength = Math.floor((normalized.length * 6) / 8)
|
||||||
|
const output = new Uint8Array(outputLength)
|
||||||
|
let accumulator = 0
|
||||||
|
let bitCount = 0
|
||||||
|
let outputIndex = 0
|
||||||
|
for (const character of normalized) {
|
||||||
|
const sextet = STANDARD_ALPHABET.indexOf(character)
|
||||||
|
if (sextet < 0) throw new Error('Base64 contains invalid characters')
|
||||||
|
accumulator = (accumulator << 6) | sextet
|
||||||
|
bitCount += 6
|
||||||
|
if (bitCount >= 8) {
|
||||||
|
bitCount -= 8
|
||||||
|
output[outputIndex] = (accumulator >>> bitCount) & 0xff
|
||||||
|
outputIndex += 1
|
||||||
|
accumulator &= (1 << bitCount) - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (accumulator !== 0) throw new Error('Base64 has non-zero trailing bits')
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { decodeBase64, encodeBase64 }
|
||||||
1
packages/common/security/canonical-json.d.ts
vendored
普通文件
1
packages/common/security/canonical-json.d.ts
vendored
普通文件
@ -0,0 +1 @@
|
|||||||
|
export function canonicalJson(value: unknown): string
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全协议唯一使用的 canonical JSON。对象键按字典序排列,null/undefined 字段省略;
|
||||||
|
* 非 JSON 协议值、数组空位和不确定的对象形态直接拒绝。
|
||||||
|
*/
|
||||||
|
function canonicalJson(value) {
|
||||||
|
if (value === null) return 'null'
|
||||||
|
if (typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value)
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (!Number.isFinite(value)) throw new TypeError('Canonical JSON requires finite numbers')
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(value, index) || value[index] === undefined) {
|
||||||
|
throw new TypeError('Canonical JSON arrays cannot contain holes or undefined')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `[${value.map(canonicalJson).join(',')}]`
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const prototype = Object.getPrototypeOf(value)
|
||||||
|
if (prototype !== Object.prototype && prototype !== null) {
|
||||||
|
throw new TypeError('Canonical JSON only supports plain objects')
|
||||||
|
}
|
||||||
|
const keys = Reflect.ownKeys(value)
|
||||||
|
if (keys.some(key => typeof key === 'symbol')) {
|
||||||
|
throw new TypeError('Canonical JSON does not support symbol keys')
|
||||||
|
}
|
||||||
|
for (const key of keys) {
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
||||||
|
if (!descriptor?.enumerable || !('value' in descriptor)) {
|
||||||
|
throw new TypeError('Canonical JSON only supports enumerable data properties')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `{${keys
|
||||||
|
.filter(key => value[key] !== null && value[key] !== undefined)
|
||||||
|
.sort()
|
||||||
|
.map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
||||||
|
.join(',')}}`
|
||||||
|
}
|
||||||
|
throw new TypeError(`Canonical JSON does not support ${typeof value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { canonicalJson }
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"xuqm-config-dev-2026-07": "Ft9MBN8jNwCfalex4wg7kuy2DRAbXkGxZyA/Jaz+6YA="
|
||||||
|
}
|
||||||
@ -13,6 +13,8 @@ export interface InternalInitOptions {
|
|||||||
|
|
||||||
export interface XuqmConfig {
|
export interface XuqmConfig {
|
||||||
appKey: string
|
appKey: string
|
||||||
|
/** 已验签配置中的宿主包名,用于绑定插件签名,禁止由业务运行时改写。 */
|
||||||
|
packageName: string
|
||||||
/** 租户平台地址:配置、更新、插件发布等 Xuqm 平台接口统一使用该地址。 */
|
/** 租户平台地址:配置、更新、插件发布等 Xuqm 平台接口统一使用该地址。 */
|
||||||
platformUrl: string
|
platformUrl: string
|
||||||
/** 业务扩展服务地址,由租户平台远程配置返回,不能代替 platformUrl。 */
|
/** 业务扩展服务地址,由租户平台远程配置返回,不能代替 platformUrl。 */
|
||||||
@ -26,6 +28,8 @@ export interface XuqmConfig {
|
|||||||
// 崩溃采集服务(rn-bugcollect 使用)
|
// 崩溃采集服务(rn-bugcollect 使用)
|
||||||
bugCollectApiUrl: string
|
bugCollectApiUrl: string
|
||||||
bugCollectEnabled: boolean
|
bugCollectEnabled: boolean
|
||||||
|
/** Update 服务必须由平台明确开启;缺失时安全关闭。 */
|
||||||
|
updateEnabled: boolean
|
||||||
/** Update 灰度检查是否要求共享登录态;由租户平台动态配置。 */
|
/** Update 灰度检查是否要求共享登录态;由租户平台动态配置。 */
|
||||||
updateRequiresLogin: boolean
|
updateRequiresLogin: boolean
|
||||||
configurationSource: 'remote' | 'cache'
|
configurationSource: 'remote' | 'cache'
|
||||||
@ -83,6 +87,7 @@ export interface XuqmRemoteConfig {
|
|||||||
pushEnabled?: boolean
|
pushEnabled?: boolean
|
||||||
bugCollectApiUrl?: string
|
bugCollectApiUrl?: string
|
||||||
bugCollectEnabled?: boolean
|
bugCollectEnabled?: boolean
|
||||||
|
updateEnabled?: boolean
|
||||||
updateRequiresLogin?: boolean
|
updateRequiresLogin?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,6 +100,7 @@ export function initConfigFromRemote(
|
|||||||
const apiUrl = remote.apiUrl ?? remote.imApiUrl ?? ''
|
const apiUrl = remote.apiUrl ?? remote.imApiUrl ?? ''
|
||||||
_config = {
|
_config = {
|
||||||
appKey: options.appKey,
|
appKey: options.appKey,
|
||||||
|
packageName: options.packageName?.trim() ?? '',
|
||||||
platformUrl,
|
platformUrl,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
imWsUrl: remote.imWsUrl ?? '',
|
imWsUrl: remote.imWsUrl ?? '',
|
||||||
@ -104,6 +110,7 @@ export function initConfigFromRemote(
|
|||||||
pushEnabled: remote.pushEnabled ?? true,
|
pushEnabled: remote.pushEnabled ?? true,
|
||||||
bugCollectApiUrl: remote.bugCollectApiUrl ?? '',
|
bugCollectApiUrl: remote.bugCollectApiUrl ?? '',
|
||||||
bugCollectEnabled: options.bugCollectMode !== 'disabled' && (remote.bugCollectEnabled ?? false),
|
bugCollectEnabled: options.bugCollectMode !== 'disabled' && (remote.bugCollectEnabled ?? false),
|
||||||
|
updateEnabled: remote.updateEnabled ?? false,
|
||||||
// 服务端未下发匿名策略时按安全默认禁止匿名更新检查。
|
// 服务端未下发匿名策略时按安全默认禁止匿名更新检查。
|
||||||
updateRequiresLogin: remote.updateRequiresLogin ?? true,
|
updateRequiresLogin: remote.updateRequiresLogin ?? true,
|
||||||
configurationSource: options.configSource ?? 'remote',
|
configurationSource: options.configSource ?? 'remote',
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { hmac } from '@noble/hashes/hmac.js'
|
import { hmac } from '@noble/hashes/hmac.js'
|
||||||
import { sha256 } from '@noble/hashes/sha2.js'
|
import { sha256 } from '@noble/hashes/sha2.js'
|
||||||
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js'
|
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js'
|
||||||
|
import { encodeBase64 } from '../security/base64.js'
|
||||||
|
|
||||||
export interface DecryptedConfig {
|
export interface DecryptedConfig {
|
||||||
schemaVersion: 2
|
schemaVersion: 2
|
||||||
@ -19,16 +20,11 @@ export interface DecryptedConfig {
|
|||||||
|
|
||||||
/** Encode arbitrary binary data without exceeding Hermes argument-count limits. */
|
/** Encode arbitrary binary data without exceeding Hermes argument-count limits. */
|
||||||
export function bytesToBase64(value: Uint8Array): string {
|
export function bytesToBase64(value: Uint8Array): string {
|
||||||
let binary = ''
|
return encodeBase64(value)
|
||||||
const chunkSize = 0x8000
|
|
||||||
for (let offset = 0; offset < value.length; offset += chunkSize) {
|
|
||||||
binary += String.fromCharCode(...value.subarray(offset, offset + chunkSize))
|
|
||||||
}
|
|
||||||
return btoa(binary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function base64UrlEncode(value: Uint8Array): string {
|
function base64UrlEncode(value: Uint8Array): string {
|
||||||
return bytesToBase64(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
return encodeBase64(value, { urlSafe: true, padding: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hmacSha256Base64Url(secret: string, data: string): string {
|
export function hmacSha256Base64Url(secret: string, data: string): string {
|
||||||
|
|||||||
@ -0,0 +1,68 @@
|
|||||||
|
import { ed25519 } from '@noble/curves/ed25519.js'
|
||||||
|
import { utf8ToBytes } from '@noble/hashes/utils.js'
|
||||||
|
import { decodeBase64 } from '../security/base64.js'
|
||||||
|
import { canonicalJson } from '../security/canonical-json.js'
|
||||||
|
|
||||||
|
const TRUSTED_SIGNING_KEYS = require('../security/trusted-signing-keys.json') as Readonly<
|
||||||
|
Record<string, string>
|
||||||
|
>
|
||||||
|
|
||||||
|
export interface SignedPluginManifest {
|
||||||
|
appKey: string
|
||||||
|
packageName: string
|
||||||
|
platform: 'ANDROID' | 'IOS'
|
||||||
|
moduleId: string
|
||||||
|
type: 'common' | 'app' | 'buz'
|
||||||
|
version: string
|
||||||
|
appVersionRange: string
|
||||||
|
builtAgainstNativeBaselineId: string
|
||||||
|
buildId: string
|
||||||
|
bundleFormat: 'hermes-bytecode' | 'javascript'
|
||||||
|
commonVersionRange?: string
|
||||||
|
minNativeApiLevel: number
|
||||||
|
bundleSha256: string
|
||||||
|
archiveSha256: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PluginManifestSignatureError extends Error {
|
||||||
|
readonly name = 'PluginManifestSignatureError'
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly code: 'UNKNOWN_SIGNING_KEY' | 'INVALID_SIGNATURE',
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal 插件候选必须在生成计划和下载前通过同一可信密钥集合验签。 */
|
||||||
|
export function verifySignedPluginManifest(
|
||||||
|
manifest: SignedPluginManifest,
|
||||||
|
keyId: string,
|
||||||
|
signature: string,
|
||||||
|
trustedKeys: Readonly<Record<string, string>> = TRUSTED_SIGNING_KEYS,
|
||||||
|
): void {
|
||||||
|
const publicKey = trustedKeys[keyId]
|
||||||
|
if (!publicKey) {
|
||||||
|
throw new PluginManifestSignatureError(
|
||||||
|
`[XuqmSecurity] Untrusted plugin signing key: ${keyId}`,
|
||||||
|
'UNKNOWN_SIGNING_KEY',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let valid = false
|
||||||
|
try {
|
||||||
|
valid = ed25519.verify(
|
||||||
|
decodeBase64(signature),
|
||||||
|
utf8ToBytes(canonicalJson(manifest)),
|
||||||
|
decodeBase64(publicKey),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
if (!valid) {
|
||||||
|
throw new PluginManifestSignatureError(
|
||||||
|
`[XuqmSecurity] Invalid plugin manifest signature: ${manifest.moduleId}@${manifest.version}`,
|
||||||
|
'INVALID_SIGNATURE',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import type { XuqmRemoteConfig } from './config'
|
||||||
|
|
||||||
|
const flatRemoteConfigSchema = z.object({
|
||||||
|
apiUrl: z.string().optional(),
|
||||||
|
imApiUrl: z.string().optional(),
|
||||||
|
imWsUrl: z.string().optional(),
|
||||||
|
fileServiceUrl: z.string().optional(),
|
||||||
|
imEnabled: z.boolean().optional(),
|
||||||
|
pushEnabled: z.boolean().optional(),
|
||||||
|
bugCollectApiUrl: z.string().optional(),
|
||||||
|
bugCollectEnabled: z.boolean().optional(),
|
||||||
|
updateEnabled: z.boolean().optional(),
|
||||||
|
updateRequiresLogin: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const remoteConfigResponseSchema = flatRemoteConfigSchema
|
||||||
|
.extend({
|
||||||
|
allowAnonymousUpdateCheck: z.boolean().optional(),
|
||||||
|
features: z
|
||||||
|
.object({
|
||||||
|
im: z.boolean().optional(),
|
||||||
|
push: z.boolean().optional(),
|
||||||
|
bugCollect: z.boolean().optional(),
|
||||||
|
update: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.loose()
|
||||||
|
|
||||||
|
/** 平台远程配置的唯一归一化入口;features 字段优先于历史 flat 字段。 */
|
||||||
|
export function normalizeRemoteConfig(value: unknown): XuqmRemoteConfig {
|
||||||
|
const raw = remoteConfigResponseSchema.parse(value)
|
||||||
|
const features = raw.features ?? {}
|
||||||
|
return {
|
||||||
|
apiUrl: raw.apiUrl,
|
||||||
|
imApiUrl: raw.imApiUrl,
|
||||||
|
imWsUrl: raw.imWsUrl,
|
||||||
|
fileServiceUrl: raw.fileServiceUrl,
|
||||||
|
imEnabled: features.im ?? raw.imEnabled,
|
||||||
|
pushEnabled: features.push ?? raw.pushEnabled,
|
||||||
|
bugCollectApiUrl: raw.bugCollectApiUrl,
|
||||||
|
bugCollectEnabled: features.bugCollect ?? raw.bugCollectEnabled,
|
||||||
|
updateEnabled: features.update ?? raw.updateEnabled,
|
||||||
|
updateRequiresLogin:
|
||||||
|
raw.updateRequiresLogin ??
|
||||||
|
(raw.allowAnonymousUpdateCheck === undefined ? undefined : !raw.allowAnonymousUpdateCheck),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旧缓存允许缺少 updateEnabled,最终由 XuqmConfig 按安全默认 false 收敛。 */
|
||||||
|
export function parseCachedRemoteConfig(value: unknown): XuqmRemoteConfig | null {
|
||||||
|
const parsed = flatRemoteConfigSchema.safeParse(value)
|
||||||
|
return parsed.success ? parsed.data : null
|
||||||
|
}
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
import { z } from 'zod'
|
|
||||||
import {
|
import {
|
||||||
_notifyInitialized,
|
_notifyInitialized,
|
||||||
_setInitializationSnapshot,
|
_setInitializationSnapshot,
|
||||||
@ -12,6 +11,7 @@ import {
|
|||||||
isInitialized,
|
isInitialized,
|
||||||
type InternalInitOptions,
|
type InternalInitOptions,
|
||||||
type XuqmLoginOptions,
|
type XuqmLoginOptions,
|
||||||
|
type XuqmRemoteConfig,
|
||||||
type XuqmUserInfo,
|
type XuqmUserInfo,
|
||||||
} from './config'
|
} from './config'
|
||||||
import { DEFAULT_TENANT_PLATFORM_URL } from './constants'
|
import { DEFAULT_TENANT_PLATFORM_URL } from './constants'
|
||||||
@ -21,6 +21,7 @@ import { _clearAccessToken, _saveAccessToken, configureHttp } from './http'
|
|||||||
import { retryWithBackoff } from './retry'
|
import { retryWithBackoff } from './retry'
|
||||||
import { isCacheRecordFresh, openLocalCache, sealLocalCache } from './secureCache'
|
import { isCacheRecordFresh, openLocalCache, sealLocalCache } from './secureCache'
|
||||||
import type { DecryptedConfig } from './crypto'
|
import type { DecryptedConfig } from './crypto'
|
||||||
|
import { normalizeRemoteConfig, parseCachedRemoteConfig } from './remoteConfig'
|
||||||
|
|
||||||
let _initPromise: Promise<void> | null = null
|
let _initPromise: Promise<void> | null = null
|
||||||
let _resolvedConfigInitPromise: Promise<void> | null = null
|
let _resolvedConfigInitPromise: Promise<void> | null = null
|
||||||
@ -33,52 +34,9 @@ let _sessionTransition: Promise<void> = Promise.resolve()
|
|||||||
let _activeSessionKey: string | null = null
|
let _activeSessionKey: string | null = null
|
||||||
const REMOTE_CONFIG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1_000
|
const REMOTE_CONFIG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1_000
|
||||||
const REMOTE_CONFIG_CACHE_PREFIX = '@xuqm_common:remote_config:v1:'
|
const REMOTE_CONFIG_CACHE_PREFIX = '@xuqm_common:remote_config:v1:'
|
||||||
const flatRemoteConfigSchema = z.object({
|
|
||||||
apiUrl: z.string().optional(),
|
|
||||||
imApiUrl: z.string().optional(),
|
|
||||||
imWsUrl: z.string().optional(),
|
|
||||||
fileServiceUrl: z.string().optional(),
|
|
||||||
imEnabled: z.boolean().optional(),
|
|
||||||
pushEnabled: z.boolean().optional(),
|
|
||||||
bugCollectApiUrl: z.string().optional(),
|
|
||||||
bugCollectEnabled: z.boolean().optional(),
|
|
||||||
updateRequiresLogin: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
const remoteConfigResponseSchema = z
|
|
||||||
.object({
|
|
||||||
apiUrl: z.string().optional(),
|
|
||||||
imApiUrl: z.string().optional(),
|
|
||||||
imWsUrl: z.string().optional(),
|
|
||||||
fileServiceUrl: z.string().optional(),
|
|
||||||
imEnabled: z.boolean().optional(),
|
|
||||||
pushEnabled: z.boolean().optional(),
|
|
||||||
bugCollectApiUrl: z.string().optional(),
|
|
||||||
bugCollectEnabled: z.boolean().optional(),
|
|
||||||
updateRequiresLogin: z.boolean().optional(),
|
|
||||||
allowAnonymousUpdateCheck: z.boolean().optional(),
|
|
||||||
features: z
|
|
||||||
.object({
|
|
||||||
im: z.boolean().optional(),
|
|
||||||
push: z.boolean().optional(),
|
|
||||||
bugCollect: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.loose()
|
|
||||||
|
|
||||||
interface CachedRemoteConfig {
|
interface CachedRemoteConfig {
|
||||||
cachedAt: number
|
cachedAt: number
|
||||||
remote: {
|
remote: XuqmRemoteConfig
|
||||||
apiUrl?: string
|
|
||||||
imApiUrl?: string
|
|
||||||
imWsUrl?: string
|
|
||||||
fileServiceUrl?: string
|
|
||||||
imEnabled?: boolean
|
|
||||||
pushEnabled?: boolean
|
|
||||||
bugCollectApiUrl?: string
|
|
||||||
bugCollectEnabled?: boolean
|
|
||||||
updateRequiresLogin?: boolean
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function runtimePlatform(): 'ANDROID' | 'IOS' {
|
function runtimePlatform(): 'ANDROID' | 'IOS' {
|
||||||
@ -112,16 +70,16 @@ async function readCachedRemoteConfig(
|
|||||||
if (!encrypted) return null
|
if (!encrypted) return null
|
||||||
const raw = openLocalCache(encrypted, signingKey, initializationKey(options))
|
const raw = openLocalCache(encrypted, signingKey, initializationKey(options))
|
||||||
const cached = JSON.parse(raw) as CachedRemoteConfig
|
const cached = JSON.parse(raw) as CachedRemoteConfig
|
||||||
const parsedRemote = flatRemoteConfigSchema.safeParse(cached?.remote)
|
const parsedRemote = parseCachedRemoteConfig(cached?.remote)
|
||||||
if (
|
if (
|
||||||
!cached ||
|
!cached ||
|
||||||
!isCacheRecordFresh(cached.cachedAt, Date.now(), REMOTE_CONFIG_CACHE_TTL_MS) ||
|
!isCacheRecordFresh(cached.cachedAt, Date.now(), REMOTE_CONFIG_CACHE_TTL_MS) ||
|
||||||
!parsedRemote.success
|
!parsedRemote
|
||||||
) {
|
) {
|
||||||
await AsyncStorage.removeItem(storageKey(options))
|
await AsyncStorage.removeItem(storageKey(options))
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return { cachedAt: cached.cachedAt, remote: parsedRemote.data }
|
return { cachedAt: cached.cachedAt, remote: parsedRemote }
|
||||||
} catch {
|
} catch {
|
||||||
await AsyncStorage.removeItem(storageKey(options)).catch(() => undefined)
|
await AsyncStorage.removeItem(storageKey(options)).catch(() => undefined)
|
||||||
return null
|
return null
|
||||||
@ -194,23 +152,7 @@ function startInitialization(options: InternalInitOptions, signingKey?: string):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
const json = (await res.json()) as Record<string, unknown>
|
const json = (await res.json()) as Record<string, unknown>
|
||||||
const raw = remoteConfigResponseSchema.parse(json.data ?? json)
|
remote = normalizeRemoteConfig(json.data ?? json)
|
||||||
const features = raw.features ?? {}
|
|
||||||
remote = {
|
|
||||||
apiUrl: raw.apiUrl,
|
|
||||||
imApiUrl: raw.imApiUrl,
|
|
||||||
imWsUrl: raw.imWsUrl,
|
|
||||||
fileServiceUrl: raw.fileServiceUrl,
|
|
||||||
imEnabled: features.im ?? raw.imEnabled,
|
|
||||||
pushEnabled: features.push ?? raw.pushEnabled,
|
|
||||||
bugCollectApiUrl: raw.bugCollectApiUrl,
|
|
||||||
bugCollectEnabled: features.bugCollect ?? raw.bugCollectEnabled,
|
|
||||||
updateRequiresLogin:
|
|
||||||
raw.updateRequiresLogin ??
|
|
||||||
(raw.allowAnonymousUpdateCheck === undefined
|
|
||||||
? undefined
|
|
||||||
: !raw.allowAnonymousUpdateCheck),
|
|
||||||
}
|
|
||||||
await writeCachedRemoteConfig(options, remote, signingKey)
|
await writeCachedRemoteConfig(options, remote, signingKey)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const cached = await readCachedRemoteConfig(options, signingKey)
|
const cached = await readCachedRemoteConfig(options, signingKey)
|
||||||
|
|||||||
@ -1,23 +1,13 @@
|
|||||||
import { gcm } from '@noble/ciphers/aes.js'
|
import { gcm } from '@noble/ciphers/aes.js'
|
||||||
import { sha256 } from '@noble/hashes/sha2.js'
|
import { sha256 } from '@noble/hashes/sha2.js'
|
||||||
import { utf8ToBytes } from '@noble/hashes/utils.js'
|
import { utf8ToBytes } from '@noble/hashes/utils.js'
|
||||||
|
import { decodeBase64, encodeBase64 } from '../security/base64.js'
|
||||||
|
|
||||||
const CACHE_FORMAT = 'XUQM-CACHE-V1'
|
const CACHE_FORMAT = 'XUQM-CACHE-V1'
|
||||||
const NONCE_BYTES = 12
|
const NONCE_BYTES = 12
|
||||||
|
|
||||||
function bytesToBase64Url(value: Uint8Array): string {
|
function bytesToBase64Url(value: Uint8Array): string {
|
||||||
let binary = ''
|
return encodeBase64(value, { urlSafe: true, padding: false })
|
||||||
for (let offset = 0; offset < value.length; offset += 0x8000) {
|
|
||||||
binary += String.fromCharCode(...value.subarray(offset, offset + 0x8000))
|
|
||||||
}
|
|
||||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64UrlToBytes(value: string): Uint8Array {
|
|
||||||
const padded =
|
|
||||||
value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4)
|
|
||||||
const binary = atob(padded)
|
|
||||||
return Uint8Array.from(binary, character => character.charCodeAt(0))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cacheKey(secret: string, context: string): Uint8Array {
|
function cacheKey(secret: string, context: string): Uint8Array {
|
||||||
@ -57,10 +47,10 @@ export function openLocalCache(payload: string, secret: string, context: string)
|
|||||||
if (parts.length !== 3 || parts[0] !== CACHE_FORMAT) {
|
if (parts.length !== 3 || parts[0] !== CACHE_FORMAT) {
|
||||||
throw new Error('[XuqmSDK] LKG cache format is invalid.')
|
throw new Error('[XuqmSDK] LKG cache format is invalid.')
|
||||||
}
|
}
|
||||||
const nonce = base64UrlToBytes(parts[1])
|
const nonce = decodeBase64(parts[1])
|
||||||
if (nonce.length !== NONCE_BYTES) throw new Error('[XuqmSDK] LKG cache nonce is invalid.')
|
if (nonce.length !== NONCE_BYTES) throw new Error('[XuqmSDK] LKG cache nonce is invalid.')
|
||||||
const plaintext = gcm(cacheKey(secret, context), nonce, utf8ToBytes(context)).decrypt(
|
const plaintext = gcm(cacheKey(secret, context), nonce, utf8ToBytes(context)).decrypt(
|
||||||
base64UrlToBytes(parts[2]),
|
decodeBase64(parts[2]),
|
||||||
)
|
)
|
||||||
return utf8BytesToString(plaintext)
|
return utf8BytesToString(plaintext)
|
||||||
}
|
}
|
||||||
|
|||||||
13
packages/common/tests/fixtures/README.md
vendored
普通文件
13
packages/common/tests/fixtures/README.md
vendored
普通文件
@ -0,0 +1,13 @@
|
|||||||
|
# 测试夹具
|
||||||
|
|
||||||
|
`rn-release-signature-vector.generated.json` 由 Server 仓库的
|
||||||
|
`update-service/src/test/resources/rn-release-signature-vector.json` 生成。
|
||||||
|
|
||||||
|
**GENERATED / DO NOT EDIT**:禁止手工修改。Server 是唯一规范源,更新时执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/sync-server-signature-vector.mjs --write
|
||||||
|
node scripts/sync-server-signature-vector.mjs --check
|
||||||
|
```
|
||||||
|
|
||||||
|
该文件只位于测试目录,不进入 `@xuqm/rn-common` 发布包。
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"keyId": "xuqm-rn-vector-2026-07",
|
||||||
|
"publicKeyRawBase64": "jkw/1R5aVg2NAwB7+xw2fR9i8phXi+2jt9l8QzuG8pU=",
|
||||||
|
"canonicalJson": "{\"appKey\":\"ak_vector\",\"appVersionRange\":\">=8.0.0 <9.0.0\",\"archiveSha256\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"buildId\":\"build-42\",\"builtAgainstNativeBaselineId\":\"android-sha256:abc\",\"bundleFormat\":\"hermes-bytecode\",\"bundleSha256\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"minNativeApiLevel\":2,\"moduleId\":\"common\",\"packageName\":\"com.xuqm.vector\",\"platform\":\"ANDROID\",\"type\":\"common\",\"version\":\"1.2.3\"}",
|
||||||
|
"signatureBase64Url": "p5GHekcL-wqM9yC7xNGRLMEwNVy7ECwVGwIdWkMJ9G85Pgr4XXca3kBXm3G-hLB0XZa76_tVmBNJhHkUuse6AQ"
|
||||||
|
}
|
||||||
@ -28,6 +28,7 @@ test('all extensions observe the same initialized config exactly once', async ()
|
|||||||
|
|
||||||
assert.equal(getConfig().apiUrl, 'https://api.example.test')
|
assert.equal(getConfig().apiUrl, 'https://api.example.test')
|
||||||
assert.equal(getConfig().platformUrl, 'https://platform.example.test')
|
assert.equal(getConfig().platformUrl, 'https://platform.example.test')
|
||||||
|
assert.equal(getConfig().updateEnabled, false)
|
||||||
assert.deepEqual(observed, ['shared-app:https://api.example.test'])
|
assert.deepEqual(observed, ['shared-app:https://api.example.test'])
|
||||||
unregister()
|
unregister()
|
||||||
})
|
})
|
||||||
|
|||||||
@ -0,0 +1,74 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { decodeBase64 } from '../security/base64.js'
|
||||||
|
import {
|
||||||
|
PluginManifestSignatureError,
|
||||||
|
verifySignedPluginManifest,
|
||||||
|
type SignedPluginManifest,
|
||||||
|
} from '../src/internalSecurity'
|
||||||
|
import { canonicalJson } from '../security/canonical-json.js'
|
||||||
|
|
||||||
|
interface ServerSignatureVector {
|
||||||
|
keyId: string
|
||||||
|
publicKeyRawBase64: string
|
||||||
|
canonicalJson: string
|
||||||
|
signatureBase64Url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const vectorPath = new URL('./fixtures/rn-release-signature-vector.generated.json', import.meta.url)
|
||||||
|
const vector = JSON.parse(readFileSync(vectorPath, 'utf8')) as ServerSignatureVector
|
||||||
|
const manifest = JSON.parse(vector.canonicalJson) as SignedPluginManifest
|
||||||
|
const trustedKeys = { [vector.keyId]: vector.publicKeyRawBase64 }
|
||||||
|
|
||||||
|
test('accepts the Server-owned Ed25519 verification vector byte-for-byte', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
Buffer.from(canonicalJson(manifest), 'utf8'),
|
||||||
|
Buffer.from(vector.canonicalJson, 'utf8'),
|
||||||
|
)
|
||||||
|
assert.equal(decodeBase64(vector.publicKeyRawBase64).length, 32)
|
||||||
|
assert.equal(decodeBase64(vector.signatureBase64Url).length, 64)
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
verifySignedPluginManifest(manifest, vector.keyId, vector.signatureBase64Url, trustedKeys),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('canonical manifest omits null fields used only by non-common modules', () => {
|
||||||
|
assert.equal(
|
||||||
|
canonicalJson({ moduleId: 'common', commonVersionRange: null }),
|
||||||
|
'{"moduleId":"common"}',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects an unknown plugin signing key', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => verifySignedPluginManifest(manifest, 'unknown', vector.signatureBase64Url, trustedKeys),
|
||||||
|
(error: unknown) =>
|
||||||
|
error instanceof PluginManifestSignatureError && error.code === 'UNKNOWN_SIGNING_KEY',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects any immutable manifest field tampering', () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
verifySignedPluginManifest(
|
||||||
|
{ ...manifest, bundleSha256: 'b'.repeat(64) },
|
||||||
|
vector.keyId,
|
||||||
|
vector.signatureBase64Url,
|
||||||
|
trustedKeys,
|
||||||
|
),
|
||||||
|
(error: unknown) =>
|
||||||
|
error instanceof PluginManifestSignatureError && error.code === 'INVALID_SIGNATURE',
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
verifySignedPluginManifest(
|
||||||
|
{ ...manifest, archiveSha256: 'd'.repeat(64) },
|
||||||
|
vector.keyId,
|
||||||
|
vector.signatureBase64Url,
|
||||||
|
trustedKeys,
|
||||||
|
),
|
||||||
|
(error: unknown) =>
|
||||||
|
error instanceof PluginManifestSignatureError && error.code === 'INVALID_SIGNATURE',
|
||||||
|
)
|
||||||
|
})
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { normalizeRemoteConfig, parseCachedRemoteConfig } from '../src/remoteConfig'
|
||||||
|
|
||||||
|
test('platform update feature is authoritative over the flat compatibility field', () => {
|
||||||
|
assert.equal(
|
||||||
|
normalizeRemoteConfig({ features: { update: false }, updateEnabled: true }).updateEnabled,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
assert.equal(normalizeRemoteConfig({ updateEnabled: true }).updateEnabled, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('missing update feature stays absent so XuqmConfig can safely default it off', () => {
|
||||||
|
assert.equal(normalizeRemoteConfig({}).updateEnabled, undefined)
|
||||||
|
assert.deepEqual(parseCachedRemoteConfig({ apiUrl: 'https://api.example.test' }), {
|
||||||
|
apiUrl: 'https://api.example.test',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cached update availability is preserved exactly', () => {
|
||||||
|
assert.equal(parseCachedRemoteConfig({ updateEnabled: true })?.updateEnabled, true)
|
||||||
|
assert.equal(parseCachedRemoteConfig({ updateEnabled: false })?.updateEnabled, false)
|
||||||
|
})
|
||||||
@ -56,6 +56,7 @@ test('awaitInitialization retries a transient config request without leaking fai
|
|||||||
await assert.doesNotReject(() => awaitInitialization())
|
await assert.doesNotReject(() => awaitInitialization())
|
||||||
assert.equal(requestCount, 2)
|
assert.equal(requestCount, 2)
|
||||||
assert.deepEqual(getInitializationSnapshot(), { state: 'ready', source: 'remote' })
|
assert.deepEqual(getInitializationSnapshot(), { state: 'ready', source: 'remote' })
|
||||||
|
assert.equal(getConfig().updateEnabled, false)
|
||||||
assert.equal(getConfig().updateRequiresLogin, true)
|
assert.equal(getConfig().updateRequiresLogin, true)
|
||||||
|
|
||||||
const result = await apiRequest<{ available: boolean }>('/api/v1/rn/release-set/check', {
|
const result = await apiRequest<{ available: boolean }>('/api/v1/rn/release-set/check', {
|
||||||
|
|||||||
@ -0,0 +1,64 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { decodeBase64, encodeBase64 } from '../security/base64.js'
|
||||||
|
import { canonicalJson } from '../security/canonical-json.js'
|
||||||
|
|
||||||
|
test('strict Base64 decoder supports padded, unpadded and URL-safe inputs without atob', () => {
|
||||||
|
assert.deepEqual([...decodeBase64('TQ==')], [77])
|
||||||
|
assert.deepEqual([...decodeBase64('TWE=')], [77, 97])
|
||||||
|
assert.deepEqual([...decodeBase64('TWFu')], [77, 97, 110])
|
||||||
|
assert.deepEqual([...decodeBase64('AQIDBA==')], [1, 2, 3, 4])
|
||||||
|
assert.deepEqual([...decodeBase64('AQIDBA')], [1, 2, 3, 4])
|
||||||
|
assert.deepEqual([...decodeBase64('-_8=')], [251, 255])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('strict Base64 decoder rejects invalid alphabets, lengths, padding and trailing bits', () => {
|
||||||
|
for (const value of [
|
||||||
|
'A',
|
||||||
|
'A+_-',
|
||||||
|
'AA=A',
|
||||||
|
'AA===',
|
||||||
|
'AAA==',
|
||||||
|
'TQ=',
|
||||||
|
'TWE==',
|
||||||
|
'TQ===',
|
||||||
|
'TQ==AA',
|
||||||
|
'TR==',
|
||||||
|
'TWF=',
|
||||||
|
'AA!A',
|
||||||
|
]) {
|
||||||
|
assert.throws(() => decodeBase64(value), /Base64/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pure JS Base64 encoder emits canonical standard and URL-safe forms', () => {
|
||||||
|
assert.equal(encodeBase64(new Uint8Array()), '')
|
||||||
|
assert.equal(encodeBase64(Uint8Array.of(77)), 'TQ==')
|
||||||
|
assert.equal(encodeBase64(Uint8Array.of(77, 97)), 'TWE=')
|
||||||
|
assert.equal(encodeBase64(Uint8Array.of(77, 97, 110)), 'TWFu')
|
||||||
|
assert.equal(encodeBase64(Uint8Array.of(251, 255), { urlSafe: true }), '-_8=')
|
||||||
|
assert.equal(encodeBase64(Uint8Array.of(251, 255), { urlSafe: true, padding: false }), '-_8')
|
||||||
|
assert.throws(() => encodeBase64([1, 2, 3] as unknown as Uint8Array), /Uint8Array/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Base64 codec round-trips chunk boundaries and matches the Node reference', () => {
|
||||||
|
for (const length of [0, 1, 2, 3, 4, 31, 32, 33, 255, 256, 257]) {
|
||||||
|
const input = Uint8Array.from({ length }, (_, index) => (index * 197 + 31) & 0xff)
|
||||||
|
const standard = encodeBase64(input)
|
||||||
|
const urlSafe = encodeBase64(input, { urlSafe: true, padding: false })
|
||||||
|
assert.equal(standard, Buffer.from(input).toString('base64'))
|
||||||
|
assert.equal(urlSafe, Buffer.from(input).toString('base64url'))
|
||||||
|
assert.deepEqual(decodeBase64(standard), input)
|
||||||
|
assert.deepEqual(decodeBase64(urlSafe), input)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('canonical JSON rejects values that have no cross-runtime protocol representation', () => {
|
||||||
|
assert.throws(() => canonicalJson(Number.NaN), /finite numbers/)
|
||||||
|
assert.throws(() => canonicalJson(Number.POSITIVE_INFINITY), /finite numbers/)
|
||||||
|
assert.throws(() => canonicalJson(() => undefined), /does not support function/)
|
||||||
|
assert.throws(() => canonicalJson(Symbol('value')), /does not support symbol/)
|
||||||
|
assert.throws(() => canonicalJson([undefined]), /cannot contain holes or undefined/)
|
||||||
|
assert.throws(() => canonicalJson(new Array(1)), /cannot contain holes or undefined/)
|
||||||
|
assert.throws(() => canonicalJson(new Date(0)), /only supports plain objects/)
|
||||||
|
})
|
||||||
@ -18,6 +18,11 @@ pnpm run xuqm:doctor
|
|||||||
- 完整 App 版本来自宿主 `package.json.version`,医网信新包从 `8.0.0` 开始。
|
- 完整 App 版本来自宿主 `package.json.version`,医网信新包从 `8.0.0` 开始。
|
||||||
- 插件默认版本来自 `xuqm.modules.json.pluginVersion`,从 `1.0.0` 开始;仅独立发布的模块覆盖 `moduleVersion`。
|
- 插件默认版本来自 `xuqm.modules.json.pluginVersion`,从 `1.0.0` 开始;仅独立发布的模块覆盖 `moduleVersion`。
|
||||||
|
|
||||||
|
`xuqm.modules.json` 分别使用 Android `packageName` 和 iOS `iosBundleId` 保存平台宿主
|
||||||
|
身份,只在目标平台 build/package/release 时要求相应字段。ZIP wire 字段仍统一为
|
||||||
|
`packageName`;CLI 通过唯一平台解析器写入对应身份,发布前从 ZIP 内重新读取并与同一
|
||||||
|
解析结果精确比较,缺失或不一致时在联网前拒绝发布。
|
||||||
|
|
||||||
Debug 启动只注册并校验插件,不访问远程更新服务。开发页如需验证平台能力,显式调用
|
Debug 启动只注册并校验插件,不访问远程更新服务。开发页如需验证平台能力,显式调用
|
||||||
`UpdateSDK.developer.checkStartupOnce()` 或
|
`UpdateSDK.developer.checkStartupOnce()` 或
|
||||||
`UpdateSDK.developer.checkAndInstallPluginOnce(moduleId)`;该状态不会跨重启保留。
|
`UpdateSDK.developer.checkAndInstallPluginOnce(moduleId)`;该状态不会跨重启保留。
|
||||||
@ -51,6 +56,7 @@ module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname))
|
|||||||
发布使用的 `XUQM_API_TOKEN` 只能由 Jenkins Credentials 注入环境变量,不属于
|
发布使用的 `XUQM_API_TOKEN` 只能由 Jenkins Credentials 注入环境变量,不属于
|
||||||
`xuqm.modules.json` 或 `config.xuqmconfig`。CLI 不读取项目文件中的 Token,避免可用凭据
|
`xuqm.modules.json` 或 `config.xuqmconfig`。CLI 不读取项目文件中的 Token,避免可用凭据
|
||||||
进入源码、插件包或构建日志。
|
进入源码、插件包或构建日志。
|
||||||
|
配置校验会直接拒绝 `release.apiToken`;发布凭据只能来自 `XUQM_API_TOKEN`。
|
||||||
|
|
||||||
Android 宿主只应用一次 SDK 脚本:
|
Android 宿主只应用一次 SDK 脚本:
|
||||||
|
|
||||||
@ -149,6 +155,24 @@ const plan = await UpdateSDK.checkAndInstallPlugin('prescription', {
|
|||||||
|
|
||||||
SDK 会验证所有已安装模块在目标 common 下仍兼容,并拒绝 `builtAgainstNativeBaselineId` 与当前完整包不一致的候选,然后整组下载、校验、staging、激活、确认或回滚。Bundle 版本与事务状态只保存在原生 state;宿主不得另建 AsyncStorage 版本表,也不得逐层传递版本或路径状态。
|
SDK 会验证所有已安装模块在目标 common 下仍兼容,并拒绝 `builtAgainstNativeBaselineId` 与当前完整包不一致的候选,然后整组下载、校验、staging、激活、确认或回滚。Bundle 版本与事务状态只保存在原生 state;宿主不得另建 AsyncStorage 版本表,也不得逐层传递版本或路径状态。
|
||||||
|
|
||||||
|
所有公开检查、下载和安装 API 都要求平台明确开启 `features.update`。关闭时在任何网络
|
||||||
|
或原生操作前抛出 `UpdateDisabledError`(`code: UPDATE_DISABLED`);登录、启动等自动
|
||||||
|
后台路径会吞掉该状态并正常返回,不影响宿主使用。
|
||||||
|
|
||||||
|
release-set 检查请求必须携带当前 `appVersion`、`nativeApiLevel` 和
|
||||||
|
`nativeBaselineId`;原生基线缺失时客户端在请求前阻断。服务端候选必须提供
|
||||||
|
`keyId/signature`,签名覆盖以下不可变 canonical manifest:
|
||||||
|
|
||||||
|
`appKey, packageName, platform, moduleId, type, version, appVersionRange, builtAgainstNativeBaselineId, buildId, bundleFormat, minNativeApiLevel, bundleSha256, archiveSha256`
|
||||||
|
|
||||||
|
app/buz 另包含 `commonVersionRange`;common 模块不构造该字段。canonical JSON 与
|
||||||
|
Config V2 一致:UTF-8、键按字典序、无空白、null/undefined 字段省略。
|
||||||
|
|
||||||
|
客户端在生成计划前及安装前各验签一次,未知 keyId、签名错误、宿主身份不匹配或任一
|
||||||
|
字段被篡改都会拒绝。响应 `sha256` 以 `archiveSha256` 进入签名清单,下载后使用同值
|
||||||
|
校验 ZIP;Android 解包层还会把已签名的
|
||||||
|
`buildId/bundleFormat/bundleSha256` 与包内 manifest 逐项比较。
|
||||||
|
|
||||||
需要先展示插件更新提示的项目,分别调用纯检查和安装 API:
|
需要先展示插件更新提示的项目,分别调用纯检查和安装 API:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|||||||
@ -19,8 +19,9 @@ import java.util.zip.ZipInputStream;
|
|||||||
final class XuqmPluginPackageStager {
|
final class XuqmPluginPackageStager {
|
||||||
|
|
||||||
private static final int MAX_ARCHIVE_BYTES = 50 * 1024 * 1024;
|
private static final int MAX_ARCHIVE_BYTES = 50 * 1024 * 1024;
|
||||||
|
private static final long MAX_ENTRY_UNCOMPRESSED_BYTES = 100L * 1024L * 1024L;
|
||||||
private static final long MAX_UNCOMPRESSED_BYTES = 100L * 1024L * 1024L;
|
private static final long MAX_UNCOMPRESSED_BYTES = 100L * 1024L * 1024L;
|
||||||
private static final int MAX_ARCHIVE_ENTRIES = 5000;
|
private static final int MAX_ARCHIVE_ENTRIES = 4096;
|
||||||
|
|
||||||
private XuqmPluginPackageStager() {}
|
private XuqmPluginPackageStager() {}
|
||||||
|
|
||||||
@ -92,10 +93,12 @@ final class XuqmPluginPackageStager {
|
|||||||
ZipEntry entry;
|
ZipEntry entry;
|
||||||
byte[] buffer = new byte[8192];
|
byte[] buffer = new byte[8192];
|
||||||
while ((entry = input.getNextEntry()) != null) {
|
while ((entry = input.getNextEntry()) != null) {
|
||||||
if (entry.isDirectory()) continue;
|
|
||||||
entryCount += 1;
|
entryCount += 1;
|
||||||
if (entryCount > MAX_ARCHIVE_ENTRIES) {
|
validateArchiveEntryCount(entryCount);
|
||||||
throw new IOException("Plugin package has too many files");
|
// CLI 的 zipSync 只生成文件 entry;显式目录 entry 不属于 v1 协议,
|
||||||
|
// 与服务端保持一致拒绝,避免目录绕过 entry 数量和路径检查。
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
throw new IOException("Plugin package directory entries are not allowed");
|
||||||
}
|
}
|
||||||
String name = entry.getName();
|
String name = entry.getName();
|
||||||
validateArchiveEntry(moduleId, name);
|
validateArchiveEntry(moduleId, name);
|
||||||
@ -108,8 +111,11 @@ final class XuqmPluginPackageStager {
|
|||||||
throw new IOException("Unable to create package directory " + parent);
|
throw new IOException("Unable to create package directory " + parent);
|
||||||
}
|
}
|
||||||
try (FileOutputStream output = new FileOutputStream(destination)) {
|
try (FileOutputStream output = new FileOutputStream(destination)) {
|
||||||
|
long entryUncompressedBytes = 0L;
|
||||||
int count;
|
int count;
|
||||||
while ((count = input.read(buffer)) != -1) {
|
while ((count = input.read(buffer)) != -1) {
|
||||||
|
entryUncompressedBytes += count;
|
||||||
|
validateEntryUncompressedSize(entryUncompressedBytes);
|
||||||
uncompressedBytes += count;
|
uncompressedBytes += count;
|
||||||
if (uncompressedBytes > MAX_UNCOMPRESSED_BYTES) {
|
if (uncompressedBytes > MAX_UNCOMPRESSED_BYTES) {
|
||||||
throw new IOException("Plugin package exceeds the uncompressed size limit");
|
throw new IOException("Plugin package exceeds the uncompressed size limit");
|
||||||
@ -122,6 +128,18 @@ final class XuqmPluginPackageStager {
|
|||||||
return archiveEntries;
|
return archiveEntries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void validateArchiveEntryCount(int entryCount) throws IOException {
|
||||||
|
if (entryCount > MAX_ARCHIVE_ENTRIES) {
|
||||||
|
throw new IOException("Plugin package has too many entries");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void validateEntryUncompressedSize(long entryBytes) throws IOException {
|
||||||
|
if (entryBytes > MAX_ENTRY_UNCOMPRESSED_BYTES) {
|
||||||
|
throw new IOException("Plugin package entry exceeds the uncompressed size limit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static JSONObject readAndValidateManifest(
|
private static JSONObject readAndValidateManifest(
|
||||||
String moduleId,
|
String moduleId,
|
||||||
File packageDirectory,
|
File packageDirectory,
|
||||||
@ -199,6 +217,9 @@ final class XuqmPluginPackageStager {
|
|||||||
requireEqual(candidate, manifest, "moduleId");
|
requireEqual(candidate, manifest, "moduleId");
|
||||||
requireEqual(candidate, manifest, "type");
|
requireEqual(candidate, manifest, "type");
|
||||||
requireEqual(candidate, manifest, "version");
|
requireEqual(candidate, manifest, "version");
|
||||||
|
requireEqual(candidate, manifest, "buildId");
|
||||||
|
requireEqual(candidate, manifest, "bundleFormat");
|
||||||
|
requireEqual(candidate, manifest, "bundleSha256");
|
||||||
requireEqual(candidate, manifest, "appVersionRange");
|
requireEqual(candidate, manifest, "appVersionRange");
|
||||||
requireOptionalEqual(candidate, manifest, "builtAgainstNativeBaselineId");
|
requireOptionalEqual(candidate, manifest, "builtAgainstNativeBaselineId");
|
||||||
requireOptionalEqual(candidate, manifest, "commonVersionRange");
|
requireOptionalEqual(candidate, manifest, "commonVersionRange");
|
||||||
|
|||||||
@ -18,4 +18,18 @@ public final class XuqmPluginPackageStagerTest {
|
|||||||
IllegalArgumentException.class,
|
IllegalArgumentException.class,
|
||||||
() -> XuqmPluginPackageStager.validateAssetPath("assets/free-form-name.png"));
|
() -> XuqmPluginPackageStager.validateAssetPath("assets/free-form-name.png"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void enforcesTheV1ArchiveEntryBoundaries() throws Exception {
|
||||||
|
XuqmPluginPackageStager.validateArchiveEntryCount(4096);
|
||||||
|
assertThrows(
|
||||||
|
java.io.IOException.class,
|
||||||
|
() -> XuqmPluginPackageStager.validateArchiveEntryCount(4097));
|
||||||
|
|
||||||
|
XuqmPluginPackageStager.validateEntryUncompressedSize(100L * 1024L * 1024L);
|
||||||
|
assertThrows(
|
||||||
|
java.io.IOException.class,
|
||||||
|
() -> XuqmPluginPackageStager.validateEntryUncompressedSize(
|
||||||
|
100L * 1024L * 1024L + 1L));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import path from 'node:path'
|
|||||||
/**
|
/**
|
||||||
* 整包与插件发布共用的配置验证门禁。只阻断新产物;已安装 App 不在线查询吊销状态。
|
* 整包与插件发布共用的配置验证门禁。只阻断新产物;已安装 App 不在线查询吊销状态。
|
||||||
*/
|
*/
|
||||||
export async function validateReleaseConfig({ projectRoot, packageName, platform }) {
|
export async function validateReleaseConfig({ projectRoot, packageName }) {
|
||||||
const hostRequire = createRequire(path.join(projectRoot, 'package.json'))
|
const hostRequire = createRequire(path.join(projectRoot, 'package.json'))
|
||||||
const commonMetro = hostRequire('@xuqm/rn-common/metro')
|
const commonMetro = hostRequire('@xuqm/rn-common/metro')
|
||||||
const configFile = commonMetro.findConfigFile(projectRoot)
|
const configFile = commonMetro.findConfigFile(projectRoot)
|
||||||
@ -15,16 +15,15 @@ export async function validateReleaseConfig({ projectRoot, packageName, platform
|
|||||||
|
|
||||||
const content = readFileSync(configFile, 'utf8').trim()
|
const content = readFileSync(configFile, 'utf8').trim()
|
||||||
const resolved = commonMetro.decryptConfigAtBuildTime(content)
|
const resolved = commonMetro.decryptConfigAtBuildTime(content)
|
||||||
const targetPackageName =
|
if (typeof packageName !== 'string' || packageName.trim() === '') {
|
||||||
packageName ??
|
throw new Error('A package identity is required for Release validation')
|
||||||
(platform === 'ios' ? (resolved.iosBundleId ?? resolved.packageName) : resolved.packageName)
|
}
|
||||||
if (!targetPackageName) throw new Error('A package identity is required for Release validation')
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${String(resolved.serverUrl).replace(/\/$/, '')}/api/sdk/build/config/validate`,
|
`${String(resolved.serverUrl).replace(/\/$/, '')}/api/sdk/build/config/validate`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content, packageName: targetPackageName }),
|
body: JSON.stringify({ content, packageName }),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export function readReleasePackageIdentity(packageFile, expected) {
|
|||||||
throw new Error('plugin package manifest is invalid JSON')
|
throw new Error('plugin package manifest is invalid JSON')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const packageName = requiredString(manifest, 'packageName')
|
||||||
const moduleId = requiredString(manifest, 'moduleId')
|
const moduleId = requiredString(manifest, 'moduleId')
|
||||||
const platform = requiredString(manifest, 'platform')
|
const platform = requiredString(manifest, 'platform')
|
||||||
const moduleVersion = requiredString(manifest, 'version')
|
const moduleVersion = requiredString(manifest, 'version')
|
||||||
@ -41,6 +42,11 @@ export function readReleasePackageIdentity(packageFile, expected) {
|
|||||||
if (buildId === 'development') {
|
if (buildId === 'development') {
|
||||||
throw new Error('plugin package was built without a release buildId; rebuild before publishing')
|
throw new Error('plugin package was built without a release buildId; rebuild before publishing')
|
||||||
}
|
}
|
||||||
|
if (expected.packageName !== packageName) {
|
||||||
|
throw new Error(
|
||||||
|
`plugin package packageName mismatch: expected ${expected.packageName}, got ${packageName}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (expected.moduleId !== moduleId) {
|
if (expected.moduleId !== moduleId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`plugin package moduleId mismatch: expected ${expected.moduleId}, got ${moduleId}`,
|
`plugin package moduleId mismatch: expected ${expected.moduleId}, got ${moduleId}`,
|
||||||
@ -83,6 +89,7 @@ export function readReleasePackageIdentity(packageFile, expected) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
manifest,
|
manifest,
|
||||||
|
packageName,
|
||||||
moduleId,
|
moduleId,
|
||||||
platform,
|
platform,
|
||||||
moduleVersion,
|
moduleVersion,
|
||||||
|
|||||||
@ -11,6 +11,7 @@ function createPackage(root, overrides = {}, bundle = 'bundle') {
|
|||||||
const bundleBytes = strToU8(bundle)
|
const bundleBytes = strToU8(bundle)
|
||||||
const manifest = {
|
const manifest = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
|
packageName: 'com.example.host',
|
||||||
moduleId: 'app',
|
moduleId: 'app',
|
||||||
platform: 'android',
|
platform: 'android',
|
||||||
version: '1.0.1',
|
version: '1.0.1',
|
||||||
@ -37,6 +38,7 @@ function createPackage(root, overrides = {}, bundle = 'bundle') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const expected = {
|
const expected = {
|
||||||
|
packageName: 'com.example.host',
|
||||||
moduleId: 'app',
|
moduleId: 'app',
|
||||||
platform: 'android',
|
platform: 'android',
|
||||||
moduleVersion: '1.0.1',
|
moduleVersion: '1.0.1',
|
||||||
@ -47,6 +49,7 @@ test('release identity is read from and verified against the packaged Bundle', (
|
|||||||
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-package-'))
|
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-package-'))
|
||||||
try {
|
try {
|
||||||
const identity = readReleasePackageIdentity(createPackage(root), expected)
|
const identity = readReleasePackageIdentity(createPackage(root), expected)
|
||||||
|
assert.equal(identity.packageName, 'com.example.host')
|
||||||
assert.equal(identity.buildId, 'build-20260726')
|
assert.equal(identity.buildId, 'build-20260726')
|
||||||
assert.equal(identity.bundleFormat, 'hermes-bytecode')
|
assert.equal(identity.bundleFormat, 'hermes-bytecode')
|
||||||
assert.equal(identity.nativeBaselineId, 'native-1')
|
assert.equal(identity.nativeBaselineId, 'native-1')
|
||||||
@ -58,6 +61,26 @@ test('release identity is read from and verified against the packaged Bundle', (
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('publishing requires and exactly matches the packaged packageName', () => {
|
||||||
|
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-package-name-'))
|
||||||
|
try {
|
||||||
|
assert.throws(
|
||||||
|
() => readReleasePackageIdentity(createPackage(root, { packageName: undefined }), expected),
|
||||||
|
/manifest packageName is missing/,
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
readReleasePackageIdentity(
|
||||||
|
createPackage(root, { packageName: 'com.example.other' }),
|
||||||
|
expected,
|
||||||
|
),
|
||||||
|
/packageName mismatch: expected com\.example\.host, got com\.example\.other/,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('publishing rejects development identity and mismatched packaged Bundle hash', () => {
|
test('publishing rejects development identity and mismatched packaged Bundle hash', () => {
|
||||||
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-package-invalid-'))
|
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-package-invalid-'))
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -27,6 +27,18 @@ export function readConfig(root = process.cwd()) {
|
|||||||
export function validateConfig(config, root = process.cwd()) {
|
export function validateConfig(config, root = process.cwd()) {
|
||||||
const errors = []
|
const errors = []
|
||||||
if (!config.appId) errors.push('appId is required')
|
if (!config.appId) errors.push('appId is required')
|
||||||
|
if (
|
||||||
|
config.packageName !== undefined &&
|
||||||
|
(typeof config.packageName !== 'string' || config.packageName.trim() === '')
|
||||||
|
) {
|
||||||
|
errors.push('packageName must be a non-empty string')
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
config.iosBundleId !== undefined &&
|
||||||
|
(typeof config.iosBundleId !== 'string' || config.iosBundleId.trim() === '')
|
||||||
|
) {
|
||||||
|
errors.push('iosBundleId must be a non-empty string')
|
||||||
|
}
|
||||||
if (!config.mainModuleName) errors.push('mainModuleName is required')
|
if (!config.mainModuleName) errors.push('mainModuleName is required')
|
||||||
if (typeof config.pluginVersion !== 'string' || !semver.valid(config.pluginVersion)) {
|
if (typeof config.pluginVersion !== 'string' || !semver.valid(config.pluginVersion)) {
|
||||||
errors.push('pluginVersion must be SemVer')
|
errors.push('pluginVersion must be SemVer')
|
||||||
@ -34,6 +46,9 @@ export function validateConfig(config, root = process.cwd()) {
|
|||||||
if (typeof config.appVersionRange !== 'string' || !semver.validRange(config.appVersionRange)) {
|
if (typeof config.appVersionRange !== 'string' || !semver.validRange(config.appVersionRange)) {
|
||||||
errors.push('appVersionRange must be a valid SemVer range')
|
errors.push('appVersionRange must be a valid SemVer range')
|
||||||
}
|
}
|
||||||
|
if (config.release && Object.prototype.hasOwnProperty.call(config.release, 'apiToken')) {
|
||||||
|
errors.push('release.apiToken is forbidden; Jenkins must inject XUQM_API_TOKEN')
|
||||||
|
}
|
||||||
if (!Array.isArray(config.modules) || config.modules.length === 0) {
|
if (!Array.isArray(config.modules) || config.modules.length === 0) {
|
||||||
errors.push('modules must contain at least one module')
|
errors.push('modules must contain at least one module')
|
||||||
return errors
|
return errors
|
||||||
@ -103,6 +118,22 @@ export function validateConfig(config, root = process.cwd()) {
|
|||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* wire 协议统一使用 packageName,但值必须是目标平台自己的宿主身份。所有构建、校验
|
||||||
|
* 与发布入口只能通过这里解析,避免 Android applicationId 泄漏到 iOS 插件。
|
||||||
|
*/
|
||||||
|
export function resolveHostPackageId(config, platform) {
|
||||||
|
if (!SUPPORTED_PLATFORMS.has(platform)) {
|
||||||
|
throw new Error(`unsupported platform: ${platform}`)
|
||||||
|
}
|
||||||
|
const key = platform === 'ios' ? 'iosBundleId' : 'packageName'
|
||||||
|
const value = config?.[key]
|
||||||
|
if (typeof value !== 'string' || value.trim() === '') {
|
||||||
|
throw new Error(`${key} is required for ${platform} build and release`)
|
||||||
|
}
|
||||||
|
return value.trim()
|
||||||
|
}
|
||||||
|
|
||||||
export function readPackageVersion(root = process.cwd()) {
|
export function readPackageVersion(root = process.cwd()) {
|
||||||
const packageFile = path.join(root, 'package.json')
|
const packageFile = path.join(root, 'package.json')
|
||||||
if (!existsSync(packageFile)) throw new Error('package.json is missing')
|
if (!existsSync(packageFile)) throw new Error('package.json is missing')
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import { resolveVersionedModules, validateConfig } from './xuqm-config.mjs'
|
import { resolveHostPackageId, resolveVersionedModules, validateConfig } from './xuqm-config.mjs'
|
||||||
|
|
||||||
function fixture() {
|
function fixture() {
|
||||||
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-config-'))
|
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-config-'))
|
||||||
@ -19,6 +19,7 @@ test('plugins start at the configured plugin version independently from the app
|
|||||||
try {
|
try {
|
||||||
const config = {
|
const config = {
|
||||||
appId: 'example',
|
appId: 'example',
|
||||||
|
packageName: 'com.example.host',
|
||||||
mainModuleName: 'Example',
|
mainModuleName: 'Example',
|
||||||
pluginVersion: '1.0.0',
|
pluginVersion: '1.0.0',
|
||||||
appVersionRange: '>=7.2.14 <8.0.0',
|
appVersionRange: '>=7.2.14 <8.0.0',
|
||||||
@ -70,6 +71,7 @@ test('only common can own existing shared source roots', () => {
|
|||||||
mkdirSync(path.join(root, 'src', 'common'), { recursive: true })
|
mkdirSync(path.join(root, 'src', 'common'), { recursive: true })
|
||||||
const base = {
|
const base = {
|
||||||
appId: 'example',
|
appId: 'example',
|
||||||
|
packageName: 'com.example.host',
|
||||||
mainModuleName: 'Example',
|
mainModuleName: 'Example',
|
||||||
pluginVersion: '1.0.0',
|
pluginVersion: '1.0.0',
|
||||||
appVersionRange: '>=1.0.0 <2.0.0',
|
appVersionRange: '>=1.0.0 <2.0.0',
|
||||||
@ -113,3 +115,42 @@ test('only common can own existing shared source roots', () => {
|
|||||||
rmSync(root, { recursive: true, force: true })
|
rmSync(root, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('release token cannot be stored in xuqm.modules.json', () => {
|
||||||
|
const root = fixture()
|
||||||
|
try {
|
||||||
|
const errors = validateConfig(
|
||||||
|
{
|
||||||
|
appId: 'example',
|
||||||
|
packageName: 'com.example.host',
|
||||||
|
mainModuleName: 'Example',
|
||||||
|
pluginVersion: '1.0.0',
|
||||||
|
appVersionRange: '>=1.0.0 <2.0.0',
|
||||||
|
metroConfig: './metro.config.js',
|
||||||
|
release: { apiToken: 'must-not-enter-source' },
|
||||||
|
modules: [{ id: 'common', type: 'common', entry: './common.ts' }],
|
||||||
|
},
|
||||||
|
root,
|
||||||
|
)
|
||||||
|
assert.match(errors.join('\n'), /release\.apiToken is forbidden.*XUQM_API_TOKEN/)
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('host package identity resolves exactly once for each target platform', () => {
|
||||||
|
const config = {
|
||||||
|
packageName: 'com.example.android',
|
||||||
|
iosBundleId: 'com.example.ios',
|
||||||
|
}
|
||||||
|
assert.equal(resolveHostPackageId(config, 'android'), 'com.example.android')
|
||||||
|
assert.equal(resolveHostPackageId(config, 'ios'), 'com.example.ios')
|
||||||
|
assert.throws(
|
||||||
|
() => resolveHostPackageId({ packageName: 'com.example.android' }, 'ios'),
|
||||||
|
/iosBundleId is required for ios build and release/,
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() => resolveHostPackageId({ iosBundleId: 'com.example.ios' }, 'android'),
|
||||||
|
/packageName is required for android build and release/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import {
|
|||||||
compatibleAppRange,
|
compatibleAppRange,
|
||||||
parseModuleOptions,
|
parseModuleOptions,
|
||||||
readConfig,
|
readConfig,
|
||||||
|
resolveHostPackageId,
|
||||||
resolveVersionedModules,
|
resolveVersionedModules,
|
||||||
selectModules,
|
selectModules,
|
||||||
validateConfig,
|
validateConfig,
|
||||||
@ -47,23 +48,10 @@ function writeJson(file, value) {
|
|||||||
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`)
|
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function androidApplicationId() {
|
async function validateReleaseConfigOnline(config, platform) {
|
||||||
const gradleFile = ['android/app/build.gradle', 'android/app/build.gradle.kts']
|
|
||||||
.map(file => path.resolve(root, file))
|
|
||||||
.find(existsSync)
|
|
||||||
if (!gradleFile) throw new Error('Android app Gradle file is missing')
|
|
||||||
const match = readFileSync(gradleFile, 'utf8').match(
|
|
||||||
/\bapplicationId\s*(?:=|\s)\s*["']([^"']+)["']/,
|
|
||||||
)
|
|
||||||
if (!match?.[1]) throw new Error('Android applicationId could not be resolved')
|
|
||||||
return match[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateReleaseConfigOnline(platform) {
|
|
||||||
await validateReleaseConfig({
|
await validateReleaseConfig({
|
||||||
projectRoot: root,
|
projectRoot: root,
|
||||||
packageName: platform === 'android' ? androidApplicationId() : undefined,
|
packageName: resolveHostPackageId(config, platform),
|
||||||
platform,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,6 +152,7 @@ function init() {
|
|||||||
writeJson(configFile, {
|
writeJson(configFile, {
|
||||||
schemaVersion: 3,
|
schemaVersion: 3,
|
||||||
appId: pkg.name ?? 'react-native-app',
|
appId: pkg.name ?? 'react-native-app',
|
||||||
|
packageName: pkg.name ?? 'react-native-app',
|
||||||
mainModuleName: pkg.name ?? 'ReactNativeApp',
|
mainModuleName: pkg.name ?? 'ReactNativeApp',
|
||||||
pluginVersion: '1.0.0',
|
pluginVersion: '1.0.0',
|
||||||
appVersionRange: compatibleAppRange(pkg.version ?? '1.0.0'),
|
appVersionRange: compatibleAppRange(pkg.version ?? '1.0.0'),
|
||||||
@ -334,6 +323,7 @@ function buildEntryFile(module, platform) {
|
|||||||
function build(platform, optionArgs = []) {
|
function build(platform, optionArgs = []) {
|
||||||
assertPlatform(platform)
|
assertPlatform(platform)
|
||||||
const config = doctor()
|
const config = doctor()
|
||||||
|
resolveHostPackageId(config, platform)
|
||||||
const versioned = resolveVersionedModules(config, root, process.env.XUQM_APP_VERSION)
|
const versioned = resolveVersionedModules(config, root, process.env.XUQM_APP_VERSION)
|
||||||
const versionedConfig = { ...config, modules: versioned.modules }
|
const versionedConfig = { ...config, modules: versioned.modules }
|
||||||
const modules = parseSelectedModules(versionedConfig, optionArgs)
|
const modules = parseSelectedModules(versionedConfig, optionArgs)
|
||||||
@ -436,6 +426,7 @@ function createModulePackage(config, platform, module, appVersion, nativeBaselin
|
|||||||
})
|
})
|
||||||
const manifest = {
|
const manifest = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
|
packageName: resolveHostPackageId(config, platform),
|
||||||
moduleId: module.id,
|
moduleId: module.id,
|
||||||
type: module.type,
|
type: module.type,
|
||||||
platform,
|
platform,
|
||||||
@ -636,7 +627,8 @@ async function packageApp(platform, optionArgs) {
|
|||||||
fail(error instanceof Error ? error.message : String(error))
|
fail(error instanceof Error ? error.message : String(error))
|
||||||
}
|
}
|
||||||
const { apk, bugCollectMode } = packageOptions
|
const { apk, bugCollectMode } = packageOptions
|
||||||
await validateReleaseConfigOnline(platform)
|
const config = doctor()
|
||||||
|
await validateReleaseConfigOnline(config, platform)
|
||||||
const buildIdentity = createAndroidReleaseIdentity({ projectRoot: root })
|
const buildIdentity = createAndroidReleaseIdentity({ projectRoot: root })
|
||||||
const androidDirectory = path.join(root, 'android')
|
const androidDirectory = path.join(root, 'android')
|
||||||
const wrapper = path.join(
|
const wrapper = path.join(
|
||||||
|
|||||||
@ -52,6 +52,8 @@ test('embed creates a complete versioned manifest from a host fixture', () => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
schemaVersion: 3,
|
schemaVersion: 3,
|
||||||
appId: 'fixture.app',
|
appId: 'fixture.app',
|
||||||
|
packageName: 'com.example.fixture',
|
||||||
|
iosBundleId: 'com.example.fixture.ios',
|
||||||
mainModuleName: 'Fixture',
|
mainModuleName: 'Fixture',
|
||||||
pluginVersion: '1.0.0',
|
pluginVersion: '1.0.0',
|
||||||
appVersionRange: '>=7.2.14 <8.0.0',
|
appVersionRange: '>=7.2.14 <8.0.0',
|
||||||
@ -119,6 +121,7 @@ test('Android Hermes hosts compile plugin bundles to bytecode', () => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
schemaVersion: 3,
|
schemaVersion: 3,
|
||||||
appId: 'fixture.app',
|
appId: 'fixture.app',
|
||||||
|
packageName: 'com.example.fixture',
|
||||||
mainModuleName: 'Fixture',
|
mainModuleName: 'Fixture',
|
||||||
pluginVersion: '1.0.0',
|
pluginVersion: '1.0.0',
|
||||||
appVersionRange: '>=1.0.0 <2.0.0',
|
appVersionRange: '>=1.0.0 <2.0.0',
|
||||||
@ -269,6 +272,7 @@ test('embed rejects assets owned by more than one module', () => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
schemaVersion: 3,
|
schemaVersion: 3,
|
||||||
appId: 'fixture.app',
|
appId: 'fixture.app',
|
||||||
|
packageName: 'com.example.fixture',
|
||||||
mainModuleName: 'Fixture',
|
mainModuleName: 'Fixture',
|
||||||
pluginVersion: '1.0.0',
|
pluginVersion: '1.0.0',
|
||||||
appVersionRange: '>=1.0.0 <2.0.0',
|
appVersionRange: '>=1.0.0 <2.0.0',
|
||||||
@ -312,6 +316,8 @@ test('build invokes the host-local React Native CLI with a consistent color envi
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
schemaVersion: 3,
|
schemaVersion: 3,
|
||||||
appId: 'fixture.app',
|
appId: 'fixture.app',
|
||||||
|
packageName: 'com.example.fixture',
|
||||||
|
iosBundleId: 'com.example.fixture.ios',
|
||||||
mainModuleName: 'Fixture',
|
mainModuleName: 'Fixture',
|
||||||
pluginVersion: '1.0.0',
|
pluginVersion: '1.0.0',
|
||||||
appVersionRange: '>=1.0.0 <2.0.0',
|
appVersionRange: '>=1.0.0 <2.0.0',
|
||||||
@ -356,6 +362,25 @@ test('build invokes the host-local React Native CLI with a consistent color envi
|
|||||||
assert.equal(child.args[0], 'bundle')
|
assert.equal(child.args[0], 'bundle')
|
||||||
assert.equal(child.forceColor, '0')
|
assert.equal(child.forceColor, '0')
|
||||||
assert.equal(child.noColor, null)
|
assert.equal(child.noColor, null)
|
||||||
|
|
||||||
|
const androidPackage = unzipSync(
|
||||||
|
new Uint8Array(
|
||||||
|
readFileSync(path.join(root, 'bundle/android/common/common.android.xuqm.zip')),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const androidManifest = JSON.parse(strFromU8(androidPackage['rn-manifest.json']))
|
||||||
|
assert.equal(androidManifest.packageName, 'com.example.fixture')
|
||||||
|
|
||||||
|
execFileSync(process.execPath, [cli, 'build', 'ios'], {
|
||||||
|
cwd: root,
|
||||||
|
stdio: 'pipe',
|
||||||
|
})
|
||||||
|
const iosPackage = unzipSync(
|
||||||
|
new Uint8Array(readFileSync(path.join(root, 'bundle/ios/common/common.ios.xuqm.zip'))),
|
||||||
|
)
|
||||||
|
const iosManifest = JSON.parse(strFromU8(iosPackage['rn-manifest.json']))
|
||||||
|
assert.equal(iosManifest.platform, 'ios')
|
||||||
|
assert.equal(iosManifest.packageName, 'com.example.fixture.ios')
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(root, { recursive: true, force: true })
|
rmSync(root, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
@ -384,6 +409,7 @@ test('building one buz first rebuilds startup and common module maps', () => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
schemaVersion: 3,
|
schemaVersion: 3,
|
||||||
appId: 'fixture.app',
|
appId: 'fixture.app',
|
||||||
|
packageName: 'com.example.fixture',
|
||||||
mainModuleName: 'Fixture',
|
mainModuleName: 'Fixture',
|
||||||
pluginVersion: '1.0.0',
|
pluginVersion: '1.0.0',
|
||||||
appVersionRange: '>=1.0.0 <2.0.0',
|
appVersionRange: '>=1.0.0 <2.0.0',
|
||||||
@ -467,6 +493,7 @@ test('building one buz first rebuilds startup and common module maps', () => {
|
|||||||
)
|
)
|
||||||
const packageManifest = JSON.parse(strFromU8(commonPackage['rn-manifest.json']))
|
const packageManifest = JSON.parse(strFromU8(commonPackage['rn-manifest.json']))
|
||||||
assert.equal(packageManifest.schemaVersion, 1)
|
assert.equal(packageManifest.schemaVersion, 1)
|
||||||
|
assert.equal(packageManifest.packageName, 'com.example.fixture')
|
||||||
assert.equal(packageManifest.moduleId, 'common')
|
assert.equal(packageManifest.moduleId, 'common')
|
||||||
assert.equal(packageManifest.bundleFormat, 'javascript')
|
assert.equal(packageManifest.bundleFormat, 'javascript')
|
||||||
assert.deepEqual(packageManifest.assets, [])
|
assert.deepEqual(packageManifest.assets, [])
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
bundleOutputDirectory,
|
bundleOutputDirectory,
|
||||||
parseModuleOptions,
|
parseModuleOptions,
|
||||||
readConfig,
|
readConfig,
|
||||||
|
resolveHostPackageId,
|
||||||
resolveVersionedModules,
|
resolveVersionedModules,
|
||||||
selectModules,
|
selectModules,
|
||||||
} from './xuqm-config.mjs'
|
} from './xuqm-config.mjs'
|
||||||
@ -53,6 +54,7 @@ if (!['platform', 'disabled'].includes(bugCollectMode)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = readConfig(root)
|
const config = readConfig(root)
|
||||||
|
const hostPackageId = resolveHostPackageId(config, platform)
|
||||||
const versioned = resolveVersionedModules(config, root, process.env.XUQM_APP_VERSION)
|
const versioned = resolveVersionedModules(config, root, process.env.XUQM_APP_VERSION)
|
||||||
const selectedModules = selectModules({ ...config, modules: versioned.modules }, moduleIds)
|
const selectedModules = selectModules({ ...config, modules: versioned.modules }, moduleIds)
|
||||||
if (moduleIds.length > 0 && selectedModules.some(module => module.type === 'startup')) {
|
if (moduleIds.length > 0 && selectedModules.some(module => module.type === 'startup')) {
|
||||||
@ -82,8 +84,7 @@ function apiHeaders() {
|
|||||||
async function validateReleaseConfigOnline() {
|
async function validateReleaseConfigOnline() {
|
||||||
await validateReleaseConfig({
|
await validateReleaseConfig({
|
||||||
projectRoot: root,
|
projectRoot: root,
|
||||||
packageName: config.packageName,
|
packageName: hostPackageId,
|
||||||
platform,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,6 +95,7 @@ function releasePackage(module) {
|
|||||||
)
|
)
|
||||||
if (!existsSync(file)) throw new Error(`plugin package is missing: ${file}`)
|
if (!existsSync(file)) throw new Error(`plugin package is missing: ${file}`)
|
||||||
const identity = readReleasePackageIdentity(file, {
|
const identity = readReleasePackageIdentity(file, {
|
||||||
|
packageName: hostPackageId,
|
||||||
moduleId: module.id,
|
moduleId: module.id,
|
||||||
platform,
|
platform,
|
||||||
moduleVersion: module.moduleVersion,
|
moduleVersion: module.moduleVersion,
|
||||||
@ -116,7 +118,7 @@ async function uploadBundle(module, release, note) {
|
|||||||
form.append('commonVersionRange', identity.commonVersionRange ?? '')
|
form.append('commonVersionRange', identity.commonVersionRange ?? '')
|
||||||
form.append('minNativeApiLevel', String(identity.minNativeApiLevel))
|
form.append('minNativeApiLevel', String(identity.minNativeApiLevel))
|
||||||
form.append('note', note)
|
form.append('note', note)
|
||||||
if (config.packageName) form.append('packageName', config.packageName)
|
form.append('packageName', identity.packageName)
|
||||||
form.append('bundle', new Blob([readFileSync(file)]), `${module.id}.${platform}.xuqm.zip`)
|
form.append('bundle', new Blob([readFileSync(file)]), `${module.id}.${platform}.xuqm.zip`)
|
||||||
const response = await fetch(`${serverUrl}/api/v1/rn/upload`, {
|
const response = await fetch(`${serverUrl}/api/v1/rn/upload`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@ -27,6 +27,9 @@ export interface NativeReleaseModule {
|
|||||||
/** Base64-encoded .xuqm.zip bytes; never decode Hermes bytecode as text. */
|
/** Base64-encoded .xuqm.zip bytes; never decode Hermes bytecode as text. */
|
||||||
archiveBase64: string
|
archiveBase64: string
|
||||||
sha256: string
|
sha256: string
|
||||||
|
buildId: string
|
||||||
|
bundleFormat: string
|
||||||
|
bundleSha256: string
|
||||||
appVersionRange: string
|
appVersionRange: string
|
||||||
builtAgainstNativeBaselineId?: string
|
builtAgainstNativeBaselineId?: string
|
||||||
commonVersionRange?: string
|
commonVersionRange?: string
|
||||||
|
|||||||
@ -11,6 +11,10 @@ import {
|
|||||||
sha256Hex,
|
sha256Hex,
|
||||||
type DownloadProgress,
|
type DownloadProgress,
|
||||||
} from '@xuqm/rn-common'
|
} from '@xuqm/rn-common'
|
||||||
|
import {
|
||||||
|
verifySignedPluginManifest,
|
||||||
|
type SignedPluginManifest,
|
||||||
|
} from '@xuqm/rn-common/internal-security'
|
||||||
import { NativeBundle, type NativeReleaseModule } from './NativeBundle'
|
import { NativeBundle, type NativeReleaseModule } from './NativeBundle'
|
||||||
import { NativeAppUpdate } from './NativeAppUpdate'
|
import { NativeAppUpdate } from './NativeAppUpdate'
|
||||||
import { getAppVersionCode, getAppVersionName, _devSetAppVersion } from './NativeVersion'
|
import { getAppVersionCode, getAppVersionName, _devSetAppVersion } from './NativeVersion'
|
||||||
@ -22,6 +26,8 @@ import {
|
|||||||
type ReleaseSetPlan,
|
type ReleaseSetPlan,
|
||||||
} from './releaseSet'
|
} from './releaseSet'
|
||||||
import { shouldSkipUpdateForLogin } from './updateLoginPolicy'
|
import { shouldSkipUpdateForLogin } from './updateLoginPolicy'
|
||||||
|
import { assertUpdateEnabled, UpdateDisabledError } from './updateAvailability'
|
||||||
|
import { createReleaseSetCheckBody } from './releaseSetRequest'
|
||||||
|
|
||||||
export interface PluginRegistration {
|
export interface PluginRegistration {
|
||||||
moduleId: string
|
moduleId: string
|
||||||
@ -68,16 +74,23 @@ interface RawAppUpdateInfo extends Omit<AppUpdateInfo, 'sha256'> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface RawPluginReleaseCandidate {
|
interface RawPluginReleaseCandidate {
|
||||||
|
appKey: string
|
||||||
|
packageName: string
|
||||||
|
platform: 'ANDROID' | 'IOS'
|
||||||
moduleId: string
|
moduleId: string
|
||||||
type: PluginModuleType
|
type: PluginModuleType
|
||||||
version?: string
|
version: string
|
||||||
latestVersion?: string
|
|
||||||
downloadUrl: string
|
downloadUrl: string
|
||||||
sha256: string
|
sha256: string
|
||||||
appVersionRange: string
|
appVersionRange: string
|
||||||
builtAgainstNativeBaselineId?: string
|
builtAgainstNativeBaselineId: string
|
||||||
commonVersionRange?: string
|
buildId: string
|
||||||
minNativeApiLevel?: number
|
bundleFormat: 'hermes-bytecode' | 'javascript'
|
||||||
|
bundleSha256: string
|
||||||
|
commonVersionRange?: string | null
|
||||||
|
minNativeApiLevel: number
|
||||||
|
keyId: string
|
||||||
|
signature: string
|
||||||
note?: string
|
note?: string
|
||||||
forceUpdate?: boolean
|
forceUpdate?: boolean
|
||||||
changeLog?: string
|
changeLog?: string
|
||||||
@ -114,6 +127,18 @@ function isLoginRequiredWithoutSession(): boolean {
|
|||||||
return shouldSkipUpdateForLogin(getConfig().updateRequiresLogin, getUserId())
|
return shouldSkipUpdateForLogin(getConfig().updateRequiresLogin, getUserId())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireUpdateEnabled(): void {
|
||||||
|
assertUpdateEnabled(getConfig().updateEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAppVersion(operation: string): string {
|
||||||
|
const appVersion = getAppVersionName()?.trim()
|
||||||
|
if (!appVersion) {
|
||||||
|
throw new Error(`[UpdateSDK] Full-app version is required for plugin ${operation}.`)
|
||||||
|
}
|
||||||
|
return appVersion
|
||||||
|
}
|
||||||
|
|
||||||
async function clearSessionUpdateState(): Promise<void> {
|
async function clearSessionUpdateState(): Promise<void> {
|
||||||
const previousCacheKey = sessionCacheKey(UPDATE_APP_CACHE_KEY)
|
const previousCacheKey = sessionCacheKey(UPDATE_APP_CACHE_KEY)
|
||||||
activeSessionAbort.abort()
|
activeSessionAbort.abort()
|
||||||
@ -226,15 +251,21 @@ async function installedModule(registration: PluginRegistration): Promise<Instal
|
|||||||
}
|
}
|
||||||
|
|
||||||
function asCandidate(info: RawPluginReleaseCandidate): PluginReleaseCandidate {
|
function asCandidate(info: RawPluginReleaseCandidate): PluginReleaseCandidate {
|
||||||
const version = info.version ?? info.latestVersion
|
|
||||||
if (!version) throw new Error(`[UpdateSDK] Release candidate ${info.moduleId} has no version`)
|
|
||||||
return {
|
return {
|
||||||
|
appKey: info.appKey,
|
||||||
|
packageName: info.packageName,
|
||||||
|
platform: info.platform,
|
||||||
moduleId: info.moduleId,
|
moduleId: info.moduleId,
|
||||||
type: info.type,
|
type: info.type,
|
||||||
version,
|
version: info.version,
|
||||||
|
buildId: info.buildId,
|
||||||
|
bundleFormat: info.bundleFormat,
|
||||||
|
bundleSha256: info.bundleSha256,
|
||||||
|
keyId: info.keyId,
|
||||||
|
signature: info.signature,
|
||||||
downloadUrl: info.downloadUrl,
|
downloadUrl: info.downloadUrl,
|
||||||
sha256: info.sha256,
|
sha256: info.sha256,
|
||||||
commonVersionRange: info.commonVersionRange,
|
commonVersionRange: info.commonVersionRange ?? undefined,
|
||||||
appVersionRange: info.appVersionRange,
|
appVersionRange: info.appVersionRange,
|
||||||
builtAgainstNativeBaselineId: info.builtAgainstNativeBaselineId,
|
builtAgainstNativeBaselineId: info.builtAgainstNativeBaselineId,
|
||||||
minNativeApiLevel: info.minNativeApiLevel,
|
minNativeApiLevel: info.minNativeApiLevel,
|
||||||
@ -243,6 +274,45 @@ function asCandidate(info: RawPluginReleaseCandidate): PluginReleaseCandidate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function signedManifest(candidate: PluginReleaseCandidate): SignedPluginManifest {
|
||||||
|
const manifest: SignedPluginManifest = {
|
||||||
|
appKey: candidate.appKey,
|
||||||
|
packageName: candidate.packageName,
|
||||||
|
platform: candidate.platform,
|
||||||
|
moduleId: candidate.moduleId,
|
||||||
|
type: candidate.type,
|
||||||
|
version: candidate.version,
|
||||||
|
appVersionRange: candidate.appVersionRange ?? '',
|
||||||
|
builtAgainstNativeBaselineId: candidate.builtAgainstNativeBaselineId ?? '',
|
||||||
|
buildId: candidate.buildId,
|
||||||
|
bundleFormat: candidate.bundleFormat,
|
||||||
|
minNativeApiLevel: candidate.minNativeApiLevel ?? 0,
|
||||||
|
bundleSha256: candidate.bundleSha256,
|
||||||
|
archiveSha256: candidate.sha256,
|
||||||
|
}
|
||||||
|
// Config V2 canonical 规则省略 null;common 不允许构造不存在的依赖范围字段。
|
||||||
|
if (candidate.type !== 'common' && candidate.commonVersionRange) {
|
||||||
|
manifest.commonVersionRange = candidate.commonVersionRange
|
||||||
|
}
|
||||||
|
return manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyCandidate(
|
||||||
|
candidate: PluginReleaseCandidate,
|
||||||
|
expected: { appKey: string; packageName: string; platform: 'ANDROID' | 'IOS' },
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
candidate.appKey !== expected.appKey ||
|
||||||
|
candidate.packageName !== expected.packageName ||
|
||||||
|
candidate.platform !== expected.platform
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`[UpdateSDK] Signed plugin identity does not match this host: ${candidate.moduleId}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
verifySignedPluginManifest(signedManifest(candidate), candidate.keyId, candidate.signature)
|
||||||
|
}
|
||||||
|
|
||||||
function assertSha256(bytes: Uint8Array | string, expected: string, label: string): void {
|
function assertSha256(bytes: Uint8Array | string, expected: string, label: string): void {
|
||||||
if (!/^[a-f0-9]{64}$/i.test(expected)) {
|
if (!/^[a-f0-9]{64}$/i.test(expected)) {
|
||||||
throw new Error(`[UpdateSDK] ${label} response does not contain a valid SHA-256`)
|
throw new Error(`[UpdateSDK] ${label} response does not contain a valid SHA-256`)
|
||||||
@ -310,6 +380,7 @@ export const UpdateSDK = {
|
|||||||
|
|
||||||
async checkAppUpdate(bypassIgnore = false): Promise<AppUpdateInfo> {
|
async checkAppUpdate(bypassIgnore = false): Promise<AppUpdateInfo> {
|
||||||
await awaitInitialization()
|
await awaitInitialization()
|
||||||
|
requireUpdateEnabled()
|
||||||
if (isLoginRequiredWithoutSession()) {
|
if (isLoginRequiredWithoutSession()) {
|
||||||
return { needsUpdate: false, requiresLogin: true, skippedReason: 'LOGIN_REQUIRED' }
|
return { needsUpdate: false, requiresLogin: true, skippedReason: 'LOGIN_REQUIRED' }
|
||||||
}
|
}
|
||||||
@ -378,6 +449,8 @@ export const UpdateSDK = {
|
|||||||
updateInfo: AppUpdateInfo,
|
updateInfo: AppUpdateInfo,
|
||||||
options: { signal?: AbortSignal; onProgress?: (progress: UpdateDownloadProgress) => void } = {},
|
options: { signal?: AbortSignal; onProgress?: (progress: UpdateDownloadProgress) => void } = {},
|
||||||
): Promise<ArrayBuffer> {
|
): Promise<ArrayBuffer> {
|
||||||
|
await awaitInitialization()
|
||||||
|
requireUpdateEnabled()
|
||||||
if (!updateInfo.downloadUrl) throw new Error('[UpdateSDK] App update has no downloadUrl')
|
if (!updateInfo.downloadUrl) throw new Error('[UpdateSDK] App update has no downloadUrl')
|
||||||
const bytes = await downloadBytes(updateInfo.downloadUrl, {
|
const bytes = await downloadBytes(updateInfo.downloadUrl, {
|
||||||
...options,
|
...options,
|
||||||
@ -395,6 +468,8 @@ export const UpdateSDK = {
|
|||||||
onProgress?: (progress: UpdateDownloadProgress) => void
|
onProgress?: (progress: UpdateDownloadProgress) => void
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
await awaitInitialization()
|
||||||
|
requireUpdateEnabled()
|
||||||
if (!updateInfo.downloadUrl || !updateInfo.versionCode) {
|
if (!updateInfo.downloadUrl || !updateInfo.versionCode) {
|
||||||
throw new Error('[UpdateSDK] App update requires downloadUrl and versionCode')
|
throw new Error('[UpdateSDK] App update requires downloadUrl and versionCode')
|
||||||
}
|
}
|
||||||
@ -414,6 +489,7 @@ export const UpdateSDK = {
|
|||||||
options: { signal?: AbortSignal; timeoutMs?: number } = {},
|
options: { signal?: AbortSignal; timeoutMs?: number } = {},
|
||||||
): Promise<ReleaseSetPlan | null> {
|
): Promise<ReleaseSetPlan | null> {
|
||||||
await awaitInitialization()
|
await awaitInitialization()
|
||||||
|
requireUpdateEnabled()
|
||||||
if (isLoginRequiredWithoutSession()) return null
|
if (isLoginRequiredWithoutSession()) return null
|
||||||
const target = requireRegistration(targetModuleId)
|
const target = requireRegistration(targetModuleId)
|
||||||
if (target.type === 'common') {
|
if (target.type === 'common') {
|
||||||
@ -431,19 +507,29 @@ export const UpdateSDK = {
|
|||||||
throw new Error('[UpdateSDK] Exactly one common module must be registered')
|
throw new Error('[UpdateSDK] Exactly one common module must be registered')
|
||||||
}
|
}
|
||||||
const config = getConfig()
|
const config = getConfig()
|
||||||
|
if (!config.packageName) {
|
||||||
|
throw new Error('[UpdateSDK] Signed host packageName is required for plugin update checks.')
|
||||||
|
}
|
||||||
|
const platform = Platform.OS === 'android' ? 'ANDROID' : 'IOS'
|
||||||
const userId = getUserId()?.trim()
|
const userId = getUserId()?.trim()
|
||||||
|
const nativeApiLevel = await NativeBundle.getNativeApiLevel()
|
||||||
|
const nativeBaselineId = registeredNativeBaselineId(registrations) ?? ''
|
||||||
|
const appVersion = requireAppVersion('update checks')
|
||||||
const raw = await apiRequest<RawPluginReleaseSet>('/api/v1/rn/release-set/check', {
|
const raw = await apiRequest<RawPluginReleaseSet>('/api/v1/rn/release-set/check', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
skipAuth: true,
|
skipAuth: true,
|
||||||
signal: currentSessionSignal(options.signal),
|
signal: currentSessionSignal(options.signal),
|
||||||
timeoutMs: options.timeoutMs,
|
timeoutMs: options.timeoutMs,
|
||||||
body: {
|
body: createReleaseSetCheckBody({
|
||||||
appKey: config.appKey,
|
appKey: config.appKey,
|
||||||
targetModuleId,
|
targetModuleId,
|
||||||
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
|
platform,
|
||||||
userId: userId || undefined,
|
userId: userId || undefined,
|
||||||
|
appVersion,
|
||||||
|
nativeApiLevel,
|
||||||
|
nativeBaselineId,
|
||||||
installedModules: installed,
|
installedModules: installed,
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
const allowed = new Set([common.moduleId, targetModuleId])
|
const allowed = new Set([common.moduleId, targetModuleId])
|
||||||
const candidates = (raw.modules ?? raw.candidates ?? []).map(candidate => {
|
const candidates = (raw.modules ?? raw.candidates ?? []).map(candidate => {
|
||||||
@ -453,20 +539,28 @@ export const UpdateSDK = {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
const registration = requireRegistration(candidate.moduleId)
|
const registration = requireRegistration(candidate.moduleId)
|
||||||
return asCandidate({
|
const normalized = asCandidate({
|
||||||
...candidate,
|
...candidate,
|
||||||
type: registration.type,
|
|
||||||
downloadUrl: normalizeDownloadUrl(candidate.downloadUrl) ?? candidate.downloadUrl,
|
downloadUrl: normalizeDownloadUrl(candidate.downloadUrl) ?? candidate.downloadUrl,
|
||||||
commonVersionRange: candidate.commonVersionRange ?? registration.commonVersionRange,
|
|
||||||
minNativeApiLevel: candidate.minNativeApiLevel ?? registration.minNativeApiLevel,
|
|
||||||
})
|
})
|
||||||
|
if (normalized.type !== registration.type) {
|
||||||
|
throw new Error(
|
||||||
|
`[UpdateSDK] Signed plugin type does not match registration: ${candidate.moduleId}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
verifyCandidate(normalized, {
|
||||||
|
appKey: config.appKey,
|
||||||
|
packageName: config.packageName,
|
||||||
|
platform,
|
||||||
|
})
|
||||||
|
return normalized
|
||||||
})
|
})
|
||||||
const plan = planReleaseSet({
|
const plan = planReleaseSet({
|
||||||
installed,
|
installed,
|
||||||
candidates,
|
candidates,
|
||||||
nativeApiLevel: await NativeBundle.getNativeApiLevel(),
|
nativeApiLevel,
|
||||||
nativeBaselineId: registeredNativeBaselineId(registrations),
|
nativeBaselineId,
|
||||||
appVersion: getAppVersionName() ?? '0.0.0',
|
appVersion,
|
||||||
releaseId: raw.releaseId,
|
releaseId: raw.releaseId,
|
||||||
})
|
})
|
||||||
if (plan && (await readQuarantinedReleases())[targetModuleId] === plan.releaseId) {
|
if (plan && (await readQuarantinedReleases())[targetModuleId] === plan.releaseId) {
|
||||||
@ -483,7 +577,21 @@ export const UpdateSDK = {
|
|||||||
onProgress?: (moduleId: string, progress: UpdateDownloadProgress) => void
|
onProgress?: (moduleId: string, progress: UpdateDownloadProgress) => void
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<ReleaseSetPlan | null> {
|
): Promise<ReleaseSetPlan | null> {
|
||||||
|
await awaitInitialization()
|
||||||
|
requireUpdateEnabled()
|
||||||
const authorizedGeneration = sessionGeneration
|
const authorizedGeneration = sessionGeneration
|
||||||
|
const config = getConfig()
|
||||||
|
if (!config.packageName) {
|
||||||
|
throw new Error('[UpdateSDK] Signed host packageName is required for plugin installation.')
|
||||||
|
}
|
||||||
|
const platform = Platform.OS === 'android' ? 'ANDROID' : 'IOS'
|
||||||
|
for (const candidate of plan.modules) {
|
||||||
|
verifyCandidate(candidate, {
|
||||||
|
appKey: config.appKey,
|
||||||
|
packageName: config.packageName,
|
||||||
|
platform,
|
||||||
|
})
|
||||||
|
}
|
||||||
const registrations = UpdateSDK.getRegisteredPlugins()
|
const registrations = UpdateSDK.getRegisteredPlugins()
|
||||||
const installed = await Promise.all(
|
const installed = await Promise.all(
|
||||||
registrations.map(registration => installedModule(registration)),
|
registrations.map(registration => installedModule(registration)),
|
||||||
@ -498,7 +606,7 @@ export const UpdateSDK = {
|
|||||||
candidates: plan.modules,
|
candidates: plan.modules,
|
||||||
nativeApiLevel: await NativeBundle.getNativeApiLevel(),
|
nativeApiLevel: await NativeBundle.getNativeApiLevel(),
|
||||||
nativeBaselineId: registeredNativeBaselineId(registrations),
|
nativeBaselineId: registeredNativeBaselineId(registrations),
|
||||||
appVersion: getAppVersionName() ?? '0.0.0',
|
appVersion: requireAppVersion('installation'),
|
||||||
releaseId: plan.releaseId,
|
releaseId: plan.releaseId,
|
||||||
})
|
})
|
||||||
if (!verifiedPlan) return null
|
if (!verifiedPlan) return null
|
||||||
@ -519,6 +627,9 @@ export const UpdateSDK = {
|
|||||||
version: module.version,
|
version: module.version,
|
||||||
archiveBase64: bytesToBase64(archive),
|
archiveBase64: bytesToBase64(archive),
|
||||||
sha256: module.sha256,
|
sha256: module.sha256,
|
||||||
|
buildId: module.buildId,
|
||||||
|
bundleFormat: module.bundleFormat,
|
||||||
|
bundleSha256: module.bundleSha256,
|
||||||
commonVersionRange: module.commonVersionRange,
|
commonVersionRange: module.commonVersionRange,
|
||||||
appVersionRange: module.appVersionRange!,
|
appVersionRange: module.appVersionRange!,
|
||||||
builtAgainstNativeBaselineId: module.builtAgainstNativeBaselineId,
|
builtAgainstNativeBaselineId: module.builtAgainstNativeBaselineId,
|
||||||
@ -632,7 +743,14 @@ export const UpdateSDK = {
|
|||||||
/** @internal 共享登录态变化时取消旧灰度授权,并为新用户自动补检一次。 */
|
/** @internal 共享登录态变化时取消旧灰度授权,并为新用户自动补检一次。 */
|
||||||
async _handleSessionChange(loggedIn: boolean): Promise<void> {
|
async _handleSessionChange(loggedIn: boolean): Promise<void> {
|
||||||
await clearSessionUpdateState()
|
await clearSessionUpdateState()
|
||||||
if (!loggedIn || !getConfig().updateRequiresLogin || getConfig().debug) return
|
if (
|
||||||
|
!loggedIn ||
|
||||||
|
!getConfig().updateEnabled ||
|
||||||
|
!getConfig().updateRequiresLogin ||
|
||||||
|
getConfig().debug
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (automaticLoginCheckGeneration === sessionGeneration) return
|
if (automaticLoginCheckGeneration === sessionGeneration) return
|
||||||
automaticLoginCheckGeneration = sessionGeneration
|
automaticLoginCheckGeneration = sessionGeneration
|
||||||
try {
|
try {
|
||||||
@ -643,3 +761,5 @@ export const UpdateSDK = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { UpdateDisabledError }
|
||||||
|
|||||||
@ -10,7 +10,7 @@ _registerUserInfoHandler('rn-update', info => UpdateSDK._handleSessionChange(Boo
|
|||||||
_registerInitializationHandler('rn-update-reset', () => UpdateSDK._reconcileNativeReset())
|
_registerInitializationHandler('rn-update-reset', () => UpdateSDK._reconcileNativeReset())
|
||||||
_registerBundleIdentityProvider('rn-update', resolveCurrentBundleIdentity)
|
_registerBundleIdentityProvider('rn-update', resolveCurrentBundleIdentity)
|
||||||
|
|
||||||
export { StaleReleaseSetError, UpdateSDK } from './UpdateSDK'
|
export { StaleReleaseSetError, UpdateDisabledError, UpdateSDK } from './UpdateSDK'
|
||||||
export type {
|
export type {
|
||||||
PluginRegistration,
|
PluginRegistration,
|
||||||
AppUpdateInfo,
|
AppUpdateInfo,
|
||||||
|
|||||||
@ -14,7 +14,16 @@ export interface InstalledPluginModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginReleaseCandidate extends InstalledPluginModule {
|
export interface PluginReleaseCandidate extends InstalledPluginModule {
|
||||||
|
appKey: string
|
||||||
|
packageName: string
|
||||||
|
platform: 'ANDROID' | 'IOS'
|
||||||
|
buildId: string
|
||||||
|
bundleFormat: 'hermes-bytecode' | 'javascript'
|
||||||
|
bundleSha256: string
|
||||||
|
keyId: string
|
||||||
|
signature: string
|
||||||
downloadUrl: string
|
downloadUrl: string
|
||||||
|
/** 下载归档文件的 SHA-256;归档内 bundle 另由已签名 bundleSha256 约束。 */
|
||||||
sha256: string
|
sha256: string
|
||||||
forceUpdate?: boolean
|
forceUpdate?: boolean
|
||||||
changeLog?: string
|
changeLog?: string
|
||||||
@ -129,7 +138,15 @@ export function planReleaseSet(options: PlanReleaseSetOptions): ReleaseSetPlan |
|
|||||||
for (const module of installed.values()) assertModule(module)
|
for (const module of installed.values()) assertModule(module)
|
||||||
for (const candidate of candidates.values()) {
|
for (const candidate of candidates.values()) {
|
||||||
assertModule(candidate)
|
assertModule(candidate)
|
||||||
if (!candidate.downloadUrl.trim() || !/^[a-f0-9]{64}$/i.test(candidate.sha256)) {
|
if (
|
||||||
|
!candidate.downloadUrl.trim() ||
|
||||||
|
!/^[a-f0-9]{64}$/i.test(candidate.sha256) ||
|
||||||
|
!/^[a-f0-9]{64}$/i.test(candidate.bundleSha256) ||
|
||||||
|
!candidate.buildId.trim() ||
|
||||||
|
!candidate.bundleFormat.trim() ||
|
||||||
|
!candidate.keyId.trim() ||
|
||||||
|
!candidate.signature.trim()
|
||||||
|
) {
|
||||||
throw new ReleaseSetCompatibilityError(
|
throw new ReleaseSetCompatibilityError(
|
||||||
`${candidate.moduleId} must provide a download URL and SHA-256`,
|
`${candidate.moduleId} must provide a download URL and SHA-256`,
|
||||||
candidate.moduleId,
|
candidate.moduleId,
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
import type { InstalledPluginModule } from './releaseSet'
|
||||||
|
|
||||||
|
export interface ReleaseSetCheckBody {
|
||||||
|
appKey: string
|
||||||
|
targetModuleId: string
|
||||||
|
platform: 'ANDROID' | 'IOS'
|
||||||
|
userId?: string
|
||||||
|
appVersion: string
|
||||||
|
nativeApiLevel: number
|
||||||
|
nativeBaselineId: string
|
||||||
|
installedModules: InstalledPluginModule[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务端兼容性判断所需的终态请求契约,客户端收到候选后仍执行二次校验。 */
|
||||||
|
export function createReleaseSetCheckBody(input: ReleaseSetCheckBody): ReleaseSetCheckBody {
|
||||||
|
const appVersion = input.appVersion.trim()
|
||||||
|
if (!appVersion) {
|
||||||
|
throw new Error('[UpdateSDK] Full-app version is required before checking a release set.')
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(input.nativeApiLevel) || input.nativeApiLevel < 1) {
|
||||||
|
throw new Error('[UpdateSDK] Native API level must be positive before checking a release set.')
|
||||||
|
}
|
||||||
|
const nativeBaselineId = input.nativeBaselineId.trim()
|
||||||
|
if (!nativeBaselineId) {
|
||||||
|
throw new Error('[UpdateSDK] Native baseline is required before checking a plugin release set.')
|
||||||
|
}
|
||||||
|
return { ...input, appVersion, nativeBaselineId }
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
export class UpdateDisabledError extends Error {
|
||||||
|
readonly name = 'UpdateDisabledError'
|
||||||
|
readonly code = 'UPDATE_DISABLED'
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('[UpdateSDK] Update service is disabled by the platform.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 所有检查、下载和安装入口在触碰网络或原生安装状态前复用这一门禁。 */
|
||||||
|
export function assertUpdateEnabled(enabled: boolean): void {
|
||||||
|
if (!enabled) throw new UpdateDisabledError()
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { createReleaseSetCheckBody } from '../src/releaseSetRequest'
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
appKey: 'app',
|
||||||
|
targetModuleId: 'orders',
|
||||||
|
platform: 'ANDROID' as const,
|
||||||
|
appVersion: '8.0.0',
|
||||||
|
nativeApiLevel: 2,
|
||||||
|
nativeBaselineId: 'native-sha256',
|
||||||
|
installedModules: [
|
||||||
|
{
|
||||||
|
moduleId: 'app',
|
||||||
|
type: 'app' as const,
|
||||||
|
version: '1.0.0',
|
||||||
|
appVersionRange: '>=8.0.0 <9.0.0',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
test('release-set check sends all server compatibility inputs', () => {
|
||||||
|
assert.deepEqual(createReleaseSetCheckBody({ ...base, userId: 'user-1' }), {
|
||||||
|
...base,
|
||||||
|
userId: 'user-1',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('release-set check blocks locally when native baseline is unavailable', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => createReleaseSetCheckBody({ ...base, nativeBaselineId: ' ' }),
|
||||||
|
/Native baseline is required/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('release-set check blocks locally when app or native API identity is unavailable', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => createReleaseSetCheckBody({ ...base, appVersion: ' ' }),
|
||||||
|
/Full-app version is required/,
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() => createReleaseSetCheckBody({ ...base, nativeApiLevel: 0 }),
|
||||||
|
/Native API level must be positive/,
|
||||||
|
)
|
||||||
|
})
|
||||||
@ -32,13 +32,22 @@ const installed: InstalledPluginModule[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
function candidate(
|
function candidate(
|
||||||
module: Omit<PluginReleaseCandidate, 'downloadUrl' | 'sha256'>,
|
module: Pick<PluginReleaseCandidate, 'moduleId' | 'type' | 'version'> &
|
||||||
|
Partial<Omit<PluginReleaseCandidate, 'moduleId' | 'type' | 'version'>>,
|
||||||
): PluginReleaseCandidate {
|
): PluginReleaseCandidate {
|
||||||
return {
|
return {
|
||||||
|
appKey: 'app-key',
|
||||||
|
packageName: 'com.example.app',
|
||||||
|
platform: 'ANDROID',
|
||||||
appVersionRange: '>=8.0.0 <9.0.0',
|
appVersionRange: '>=8.0.0 <9.0.0',
|
||||||
...module,
|
buildId: 'build-1',
|
||||||
|
bundleFormat: 'hermes-bytecode',
|
||||||
|
bundleSha256: 'b'.repeat(64),
|
||||||
|
keyId: 'test-key',
|
||||||
|
signature: 'test-signature',
|
||||||
downloadUrl: `https://updates.example/${module.moduleId}.bundle`,
|
downloadUrl: `https://updates.example/${module.moduleId}.bundle`,
|
||||||
sha256: 'a'.repeat(64),
|
sha256: 'a'.repeat(64),
|
||||||
|
...module,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { assertUpdateEnabled, UpdateDisabledError } from '../src/updateAvailability'
|
||||||
|
|
||||||
|
test('disabled update rejects before any network operation', () => {
|
||||||
|
let networkCalls = 0
|
||||||
|
const operation = () => {
|
||||||
|
assertUpdateEnabled(false)
|
||||||
|
networkCalls += 1
|
||||||
|
}
|
||||||
|
assert.throws(operation, (error: unknown) => {
|
||||||
|
assert.equal(error instanceof UpdateDisabledError, true)
|
||||||
|
assert.equal((error as UpdateDisabledError).code, 'UPDATE_DISABLED')
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
assert.equal(networkCalls, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('explicitly enabled update allows the operation to continue', () => {
|
||||||
|
let networkCalls = 0
|
||||||
|
assertUpdateEnabled(true)
|
||||||
|
networkCalls += 1
|
||||||
|
assert.equal(networkCalls, 1)
|
||||||
|
})
|
||||||
30
pnpm-lock.yaml
自动生成的
30
pnpm-lock.yaml
自动生成的
@ -66,6 +66,9 @@ importers:
|
|||||||
'@noble/ciphers':
|
'@noble/ciphers':
|
||||||
specifier: 2.2.0
|
specifier: 2.2.0
|
||||||
version: 2.2.0
|
version: 2.2.0
|
||||||
|
'@noble/curves':
|
||||||
|
specifier: 2.2.0
|
||||||
|
version: 2.2.0
|
||||||
'@noble/hashes':
|
'@noble/hashes':
|
||||||
specifier: 2.2.0
|
specifier: 2.2.0
|
||||||
version: 2.2.0
|
version: 2.2.0
|
||||||
@ -431,6 +434,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==}
|
resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==}
|
||||||
engines: {node: '>= 20.19.0'}
|
engines: {node: '>= 20.19.0'}
|
||||||
|
|
||||||
|
'@noble/curves@2.2.0':
|
||||||
|
resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==}
|
||||||
|
engines: {node: '>= 20.19.0'}
|
||||||
|
|
||||||
'@noble/hashes@2.2.0':
|
'@noble/hashes@2.2.0':
|
||||||
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
|
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
|
||||||
engines: {node: '>= 20.19.0'}
|
engines: {node: '>= 20.19.0'}
|
||||||
@ -671,14 +678,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@xuqm/rn-common@0.6.0-alpha.9':
|
|
||||||
resolution: {integrity: sha512-NvSYr92sRV8xKVCTrO7sPFXypUYfxMozvHWaNlIk+4bnvGlhWGGUvmvjm2QxF0rBzTO8b0HzhXhb9S/CIx629Q==}
|
|
||||||
peerDependencies:
|
|
||||||
'@react-native-async-storage/async-storage': '>=1.21.0'
|
|
||||||
axios: '>=1.0.0'
|
|
||||||
react: '>=18.0.0'
|
|
||||||
react-native: '>=0.76.0'
|
|
||||||
|
|
||||||
abort-controller@3.0.0:
|
abort-controller@3.0.0:
|
||||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||||
engines: {node: '>=6.5'}
|
engines: {node: '>=6.5'}
|
||||||
@ -1862,6 +1861,10 @@ snapshots:
|
|||||||
|
|
||||||
'@noble/ciphers@2.2.0': {}
|
'@noble/ciphers@2.2.0': {}
|
||||||
|
|
||||||
|
'@noble/curves@2.2.0':
|
||||||
|
dependencies:
|
||||||
|
'@noble/hashes': 2.2.0
|
||||||
|
|
||||||
'@noble/hashes@2.2.0': {}
|
'@noble/hashes@2.2.0': {}
|
||||||
|
|
||||||
'@nozbe/lokijs@1.5.12-wmelon6': {}
|
'@nozbe/lokijs@1.5.12-wmelon6': {}
|
||||||
@ -2055,17 +2058,6 @@ snapshots:
|
|||||||
'@typescript/typescript-win32-x64@7.0.2':
|
'@typescript/typescript-win32-x64@7.0.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@xuqm/rn-common@0.6.0-alpha.9(@react-native-async-storage/async-storage@3.1.1(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(axios@1.18.1)(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)':
|
|
||||||
dependencies:
|
|
||||||
'@noble/ciphers': 2.2.0
|
|
||||||
'@noble/hashes': 2.2.0
|
|
||||||
'@react-native-async-storage/async-storage': 3.1.1(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)
|
|
||||||
axios: 1.18.1
|
|
||||||
react: 19.2.7
|
|
||||||
react-native: 0.86.0(@types/react@19.2.17)(react@19.2.7)
|
|
||||||
react-native-blob-util: 0.24.10(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)
|
|
||||||
zod: 4.4.3
|
|
||||||
|
|
||||||
abort-controller@3.0.0:
|
abort-controller@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
event-target-shim: 5.0.1
|
event-target-shim: 5.0.1
|
||||||
|
|||||||
@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const serverRoot = path.resolve(
|
||||||
|
process.env.XUQM_SERVER_ROOT || path.join(root, '..', 'XuqmGroup-Server'),
|
||||||
|
)
|
||||||
|
const source = path.join(
|
||||||
|
serverRoot,
|
||||||
|
'update-service/src/test/resources/rn-release-signature-vector.json',
|
||||||
|
)
|
||||||
|
const target = path.join(
|
||||||
|
root,
|
||||||
|
'packages/common/tests/fixtures/rn-release-signature-vector.generated.json',
|
||||||
|
)
|
||||||
|
const mode = process.argv[2]
|
||||||
|
|
||||||
|
if ((mode !== '--check' && mode !== '--write') || process.argv.length !== 3) {
|
||||||
|
throw new Error('Usage: node scripts/sync-server-signature-vector.mjs --check|--write')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVector(bytes, label) {
|
||||||
|
let vector
|
||||||
|
try {
|
||||||
|
vector = JSON.parse(bytes.toString('utf8'))
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} is not valid UTF-8 JSON`)
|
||||||
|
}
|
||||||
|
for (const field of ['keyId', 'publicKeyRawBase64', 'canonicalJson', 'signatureBase64Url']) {
|
||||||
|
if (typeof vector[field] !== 'string' || !vector[field]) {
|
||||||
|
throw new Error(`${label} is missing ${field}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(source)) {
|
||||||
|
if (mode === '--write') {
|
||||||
|
throw new Error(`Server signature vector does not exist: ${source}`)
|
||||||
|
}
|
||||||
|
console.log(`signature vector check skipped; Server checkout is unavailable: ${source}`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceBytes = readFileSync(source)
|
||||||
|
parseVector(sourceBytes, 'Server signature vector')
|
||||||
|
|
||||||
|
if (mode === '--write') {
|
||||||
|
mkdirSync(path.dirname(target), { recursive: true })
|
||||||
|
writeFileSync(target, sourceBytes)
|
||||||
|
console.log(`generated RN signature vector from Server: ${target}`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(target)) {
|
||||||
|
throw new Error(`Generated RN signature vector is missing; run with --write: ${target}`)
|
||||||
|
}
|
||||||
|
const targetBytes = readFileSync(target)
|
||||||
|
parseVector(targetBytes, 'Generated RN signature vector')
|
||||||
|
if (!sourceBytes.equals(targetBytes)) {
|
||||||
|
throw new Error(
|
||||||
|
'Generated RN signature vector differs from Server; run sync-server-signature-vector.mjs --write',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
console.log('RN signature vector matches Server byte-for-byte')
|
||||||
@ -9,6 +9,7 @@
|
|||||||
"@xuqm/rn-common/download": ["./packages/common/src/download.ts"],
|
"@xuqm/rn-common/download": ["./packages/common/src/download.ts"],
|
||||||
"@xuqm/rn-common/file-name": ["./packages/common/src/fileName.ts"],
|
"@xuqm/rn-common/file-name": ["./packages/common/src/fileName.ts"],
|
||||||
"@xuqm/rn-common/internal": ["./packages/common/src/internal.ts"],
|
"@xuqm/rn-common/internal": ["./packages/common/src/internal.ts"],
|
||||||
|
"@xuqm/rn-common/internal-security": ["./packages/common/src/internalSecurity.ts"],
|
||||||
"@xuqm/rn-common/secure-cache": ["./packages/common/src/secureCache.ts"],
|
"@xuqm/rn-common/secure-cache": ["./packages/common/src/secureCache.ts"],
|
||||||
"@xuqm/rn-common/version": ["./packages/common/src/version.ts"],
|
"@xuqm/rn-common/version": ["./packages/common/src/version.ts"],
|
||||||
"@xuqm/rn-im": ["./packages/im/src"],
|
"@xuqm/rn-im": ["./packages/im/src"],
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户