feat(update): secure signed plugin release sets

这个提交包含在:
XuqmGroup 2026-07-27 00:39:26 +08:00
父节点 3d2134e08a
当前提交 daa96302dd
共有 49 个文件被更改,包括 1135 次插入197 次删除

查看文件

@ -1,6 +1,6 @@
# RN SDK 重构实施接管文档
> 状态更新时间2026-07-26
> 状态更新时间2026-07-27
> 当前实施范围:`@xuqm/rn-common`、`@xuqm/rn-bugcollect`、`@xuqm/rn-update`、`@xuqm/rn-xwebview`
> 发布约束:所有 XuqmGroup npm/Maven 制品和服务部署只能通过 `https://jenkins.xuqinmin.com/` 的 Jenkins 完成。
@ -32,6 +32,37 @@
补检一次并通过 `onAutomaticCheck` 交给宿主决定 UI。账号变化会取消旧下载授权和
灰度缓存;后台补检失败不影响宿主登录。服务端权威字段
`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()` 不做远程整包或插件检查;开发页仍可显式调用公开
check/install API 做一次性验证。
- 插件构建清单已从 `xuqm.config.json` 更名为 `xuqm.modules.json`;租户平台签发的
@ -51,6 +82,11 @@
- 整包和插件发布前调用 `/api/sdk/build/config/validate`。服务不可达、配置过期、
revision 撤销或包名不匹配均阻断新 Release;已安装 App 运行时不查询吊销状态。
- 相同 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_VERSION_CODE``XUQM_BUILD_ID` 注入全局唯一流水线身份。
- Gradle 内嵌任务把 `buildId``bugCollectMode` 声明为显式 `@Input` 并传入 embed
@ -62,14 +98,15 @@
- 原生最终恢复入口只删除 SDK 私有 `files/rn-bundles`,并递增 reset generation;
common/app 初始化后仅清除 `xuqm_update_*` 状态并确认该 generation,重试不会误清
宿主、BugCollect 或签名 SDK 数据。
- 已执行全部包类型检查、common 30 项、bugcollect 11 项、update CLI 25 项、update
领域 13 项、xwebview 7 项测试、update 包内容校验、Prettier 和 `git diff --check`
均通过;update 发布包内容为 49 个文件。`pnpm validate` 本次因 Corepack/pnpm
尝试联网读取 registry 且要求交互确认重建 `node_modules` 而未执行成功,已使用仓内
已安装工具逐项执行等价门禁。`native-test` 的 Gradle 在任务配置前因本机
`libnative-platform.dylib` 无法加载而失败,不能据此声称本轮原生 Java 已编译。
尚未执行宿主 Android 构建、设备运行、开发环境接口和真实 Source Map 上传;这些
结果不得从静态检查推断。
- 已执行 `pnpm validate`全部包类型检查、common 42 项、bugcollect 11 项、update
CLI 28 项、update 领域 18 项、xwebview 7 项和 IM 3 项测试均通过;update 包内容
校验通过,发布包为 51 个文件;Prettier 和 `git diff --check` 通过。
独立 `native-test` 的系统 Gradle 仍在任务配置前因本机
`libnative-platform.dylib` 无法加载;改用 App4 Gradle 9.3.1 wrapper 后,
`:xuqm_rn-update:testDebugUnitTest --tests
com.xuqm.update.XuqmPluginPackageStagerTest` 共 25 tasks 构建并测试成功。
尚未执行完整宿主 Android 构建、设备运行、开发环境接口和真实 Source Map 上传;
这些结果不得从当前门禁推断。
- License 离线激活 SDK 已审计RNSDK 不存在对应包、API、依赖、脚本、文档或空目录;
npm 的 `license: UNLICENSED` 元数据和 RN 插件事务 `activation` 不属于离线授权。
- 配置中的 `signingKey` 明确是客户端可提取的请求完整性/缓存派生材料,不是 License、
@ -156,7 +193,8 @@
### 3.3 已确认缺口
- 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 版本。
- 客户端已改为 `/api/v1/rn/release-set/check` 单次请求目标入口依赖闭包;租户平台服务端接口尚待同步实现。
- `nativeBaselineId` 已自动计算并进入内嵌 manifest/插件上传:覆盖 Android 原生源码、Gradle 配置、RN 与含原生代码的 npm 依赖,普通业务 JS 不影响指纹;Jenkins/服务端登记校验尚待实现。

查看文件

@ -67,6 +67,11 @@ await XuqmSDK.logout()
## update 插件检查边界
Update 服务由平台远程配置 `features.update` 控制,缺失时默认关闭。显式调用
`checkAppUpdate`、`downloadApk`、`downloadAndInstallApp`、`checkPluginRelease` 或
`installPluginRelease` 时,关闭状态统一抛出 `UpdateDisabledError`,错误码为
`UPDATE_DISABLED`,且不会先访问网络或原生安装模块;自动启动检查会正常跳过。
```ts
const plan = await UpdateSDK.checkPluginRelease('buz1', {
signal,
@ -87,4 +92,8 @@ await UpdateSDK.checkAndInstallPlugin('buz1', {
- `checkPluginRelease()` 只检查版本和兼容依赖闭包,不下载、不激活;`timeoutMs` 只约束检查请求。
- `installPluginRelease()` 只安装此前确认的计划;本地基线已变化时抛出 `StaleReleaseSetError`
- `checkAndInstallPlugin()` 用于宿主自动进入插件的场景;`checkTimeoutMs` 只传给检查阶段,下载仍由 `signal` 和进度回调控制。
- release-set 请求固定包含 `appVersion`、`nativeApiLevel`、`nativeBaselineId`;缺少
native baseline 时不发请求。服务端返回的插件候选必须具有平台 Ed25519
`keyId/signature`,客户端在计划前和安装前验证不可变 manifest。
候选响应的 ZIP `sha256``archiveSha256` 纳入签名,下载后继续以同一值校验归档。
- common HTTP 的统一默认请求上限为 30 秒;宿主需要更短的交互等待时显式传上述参数,不得在页面复制 `Promise.race` 或第二套超时器。

查看文件

@ -11,6 +11,16 @@ pnpm xuqm:doctor
`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 包装脚本:
```bash

查看文件

@ -18,12 +18,13 @@
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
},
"scripts": {
"format": "prettier --write 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 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 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",
"typecheck": "tsc --noEmit",
"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": {
"@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 非法会直接阻断构建,禁止打出运行时才失败的安装包。
平台远程配置中的 `features.update` 是 Update 服务开关;兼容字段
`updateEnabled` 只在前者缺失时读取。两者都缺失时安全默认关闭,最后成功配置缓存会
原样保存开关值。关闭 Update 不影响 common、宿主业务或其它扩展 SDK。
## 扩展包的唯一登录入口
应用登录成功后只调用一次:
@ -78,3 +82,6 @@ await XuqmSDK.logout()
- `getDeviceInfo`、`getDeviceId`、`useApi`、`usePageApi`、`showToast` 等项目通用能力。
业务代码不得导入 `@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_PASSPHRASE = 'xuqm-config-file-v2.2026.internal'
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) {
return Buffer.from(value, 'base64url')
@ -32,18 +34,6 @@ function decodeBase64Url(value) {
* 配置文件只在 Node/Metro 构建期解密移动端运行时直接消费解析后的配置
* 避免 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) {
const allowedFields = new Set([
'schemaVersion',
@ -107,7 +97,7 @@ function decryptConfigAtBuildTime(content, trustedKeys = TRUSTED_CONFIG_KEYS) {
null,
Buffer.from(unsignedToken, 'ascii'),
{
key: Buffer.from(publicKeyBase64, 'base64'),
key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(publicKeyBase64, 'base64')]),
format: 'der',
type: 'spki',
},

查看文件

@ -1,3 +0,0 @@
{
"xuqm-config-dev-2026-07": "MCowBQYDK2VwAyEAFt9MBN8jNwCfalex4wg7kuy2DRAbXkGxZyA/Jaz+6YA="
}

查看文件

@ -13,6 +13,7 @@
"./download": "./src/download.ts",
"./file-name": "./src/fileName.ts",
"./internal": "./src/internal.ts",
"./internal-security": "./src/internalSecurity.ts",
"./metro": "./metro/index.js",
"./package.json": "./package.json",
"./secure-cache": "./src/secureCache.ts",
@ -21,7 +22,8 @@
"metro": "metro/index.js",
"files": [
"src",
"metro"
"metro",
"security"
],
"private": false,
"publishConfig": {
@ -33,6 +35,7 @@
},
"dependencies": {
"@noble/ciphers": "2.2.0",
"@noble/curves": "2.2.0",
"@noble/hashes": "2.2.0",
"@types/semver": "7.7.1",
"react-native-blob-util": "0.24.10",
@ -40,18 +43,18 @@
"zod": "4.4.3"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-native": ">=0.76.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": {
"typescript": "catalog:",
"@types/react": "^19.2.14",
"@react-native-async-storage/async-storage": "^3.1.1",
"@types/react": "^19.2.14",
"axios": "^1.18.0",
"react": "19.2.7",
"react-native": "0.86.0",
"tsx": "^4.23.1"
"tsx": "^4.23.1",
"typescript": "catalog:"
}
}

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 }

查看文件

@ -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 {
appKey: string
/** 已验签配置中的宿主包名,用于绑定插件签名,禁止由业务运行时改写。 */
packageName: string
/** 租户平台地址:配置、更新、插件发布等 Xuqm 平台接口统一使用该地址。 */
platformUrl: string
/** 业务扩展服务地址,由租户平台远程配置返回,不能代替 platformUrl。 */
@ -26,6 +28,8 @@ export interface XuqmConfig {
// 崩溃采集服务rn-bugcollect 使用)
bugCollectApiUrl: string
bugCollectEnabled: boolean
/** Update 服务必须由平台明确开启;缺失时安全关闭。 */
updateEnabled: boolean
/** Update 灰度检查是否要求共享登录态;由租户平台动态配置。 */
updateRequiresLogin: boolean
configurationSource: 'remote' | 'cache'
@ -83,6 +87,7 @@ export interface XuqmRemoteConfig {
pushEnabled?: boolean
bugCollectApiUrl?: string
bugCollectEnabled?: boolean
updateEnabled?: boolean
updateRequiresLogin?: boolean
}
@ -95,6 +100,7 @@ export function initConfigFromRemote(
const apiUrl = remote.apiUrl ?? remote.imApiUrl ?? ''
_config = {
appKey: options.appKey,
packageName: options.packageName?.trim() ?? '',
platformUrl,
apiUrl,
imWsUrl: remote.imWsUrl ?? '',
@ -104,6 +110,7 @@ export function initConfigFromRemote(
pushEnabled: remote.pushEnabled ?? true,
bugCollectApiUrl: remote.bugCollectApiUrl ?? '',
bugCollectEnabled: options.bugCollectMode !== 'disabled' && (remote.bugCollectEnabled ?? false),
updateEnabled: remote.updateEnabled ?? false,
// 服务端未下发匿名策略时按安全默认禁止匿名更新检查。
updateRequiresLogin: remote.updateRequiresLogin ?? true,
configurationSource: options.configSource ?? 'remote',

查看文件

@ -1,6 +1,7 @@
import { hmac } from '@noble/hashes/hmac.js'
import { sha256 } from '@noble/hashes/sha2.js'
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js'
import { encodeBase64 } from '../security/base64.js'
export interface DecryptedConfig {
schemaVersion: 2
@ -19,16 +20,11 @@ export interface DecryptedConfig {
/** Encode arbitrary binary data without exceeding Hermes argument-count limits. */
export function bytesToBase64(value: Uint8Array): string {
let binary = ''
const chunkSize = 0x8000
for (let offset = 0; offset < value.length; offset += chunkSize) {
binary += String.fromCharCode(...value.subarray(offset, offset + chunkSize))
}
return btoa(binary)
return encodeBase64(value)
}
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 {

查看文件

@ -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 { z } from 'zod'
import {
_notifyInitialized,
_setInitializationSnapshot,
@ -12,6 +11,7 @@ import {
isInitialized,
type InternalInitOptions,
type XuqmLoginOptions,
type XuqmRemoteConfig,
type XuqmUserInfo,
} from './config'
import { DEFAULT_TENANT_PLATFORM_URL } from './constants'
@ -21,6 +21,7 @@ import { _clearAccessToken, _saveAccessToken, configureHttp } from './http'
import { retryWithBackoff } from './retry'
import { isCacheRecordFresh, openLocalCache, sealLocalCache } from './secureCache'
import type { DecryptedConfig } from './crypto'
import { normalizeRemoteConfig, parseCachedRemoteConfig } from './remoteConfig'
let _initPromise: 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
const REMOTE_CONFIG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1_000
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 {
cachedAt: number
remote: {
apiUrl?: string
imApiUrl?: string
imWsUrl?: string
fileServiceUrl?: string
imEnabled?: boolean
pushEnabled?: boolean
bugCollectApiUrl?: string
bugCollectEnabled?: boolean
updateRequiresLogin?: boolean
}
remote: XuqmRemoteConfig
}
function runtimePlatform(): 'ANDROID' | 'IOS' {
@ -112,16 +70,16 @@ async function readCachedRemoteConfig(
if (!encrypted) return null
const raw = openLocalCache(encrypted, signingKey, initializationKey(options))
const cached = JSON.parse(raw) as CachedRemoteConfig
const parsedRemote = flatRemoteConfigSchema.safeParse(cached?.remote)
const parsedRemote = parseCachedRemoteConfig(cached?.remote)
if (
!cached ||
!isCacheRecordFresh(cached.cachedAt, Date.now(), REMOTE_CONFIG_CACHE_TTL_MS) ||
!parsedRemote.success
!parsedRemote
) {
await AsyncStorage.removeItem(storageKey(options))
return null
}
return { cachedAt: cached.cachedAt, remote: parsedRemote.data }
return { cachedAt: cached.cachedAt, remote: parsedRemote }
} catch {
await AsyncStorage.removeItem(storageKey(options)).catch(() => undefined)
return null
@ -194,23 +152,7 @@ function startInitialization(options: InternalInitOptions, signingKey?: string):
},
)
const json = (await res.json()) as Record<string, unknown>
const raw = remoteConfigResponseSchema.parse(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),
}
remote = normalizeRemoteConfig(json.data ?? json)
await writeCachedRemoteConfig(options, remote, signingKey)
} catch (error) {
const cached = await readCachedRemoteConfig(options, signingKey)

查看文件

@ -1,23 +1,13 @@
import { gcm } from '@noble/ciphers/aes.js'
import { sha256 } from '@noble/hashes/sha2.js'
import { utf8ToBytes } from '@noble/hashes/utils.js'
import { decodeBase64, encodeBase64 } from '../security/base64.js'
const CACHE_FORMAT = 'XUQM-CACHE-V1'
const NONCE_BYTES = 12
function bytesToBase64Url(value: Uint8Array): string {
let binary = ''
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))
return encodeBase64(value, { urlSafe: true, padding: false })
}
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) {
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.')
const plaintext = gcm(cacheKey(secret, context), nonce, utf8ToBytes(context)).decrypt(
base64UrlToBytes(parts[2]),
decodeBase64(parts[2]),
)
return utf8BytesToString(plaintext)
}

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().platformUrl, 'https://platform.example.test')
assert.equal(getConfig().updateEnabled, false)
assert.deepEqual(observed, ['shared-app:https://api.example.test'])
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())
assert.equal(requestCount, 2)
assert.deepEqual(getInitializationSnapshot(), { state: 'ready', source: 'remote' })
assert.equal(getConfig().updateEnabled, false)
assert.equal(getConfig().updateRequiresLogin, true)
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` 开始。
- 插件默认版本来自 `xuqm.modules.json.pluginVersion`,从 `1.0.0` 开始;仅独立发布的模块覆盖 `moduleVersion`
`xuqm.modules.json` 分别使用 Android `packageName` 和 iOS `iosBundleId` 保存平台宿主
身份,只在目标平台 build/package/release 时要求相应字段。ZIP wire 字段仍统一为
`packageName`;CLI 通过唯一平台解析器写入对应身份,发布前从 ZIP 内重新读取并与同一
解析结果精确比较,缺失或不一致时在联网前拒绝发布。
Debug 启动只注册并校验插件,不访问远程更新服务。开发页如需验证平台能力,显式调用
`UpdateSDK.developer.checkStartupOnce()`
`UpdateSDK.developer.checkAndInstallPluginOnce(moduleId)`;该状态不会跨重启保留。
@ -51,6 +56,7 @@ module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname))
发布使用的 `XUQM_API_TOKEN` 只能由 Jenkins Credentials 注入环境变量,不属于
`xuqm.modules.json``config.xuqmconfig`。CLI 不读取项目文件中的 Token,避免可用凭据
进入源码、插件包或构建日志。
配置校验会直接拒绝 `release.apiToken`;发布凭据只能来自 `XUQM_API_TOKEN`
Android 宿主只应用一次 SDK 脚本:
@ -149,6 +155,24 @@ const plan = await UpdateSDK.checkAndInstallPlugin('prescription', {
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
```ts

查看文件

@ -19,8 +19,9 @@ import java.util.zip.ZipInputStream;
final class XuqmPluginPackageStager {
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 int MAX_ARCHIVE_ENTRIES = 5000;
private static final int MAX_ARCHIVE_ENTRIES = 4096;
private XuqmPluginPackageStager() {}
@ -92,10 +93,12 @@ final class XuqmPluginPackageStager {
ZipEntry entry;
byte[] buffer = new byte[8192];
while ((entry = input.getNextEntry()) != null) {
if (entry.isDirectory()) continue;
entryCount += 1;
if (entryCount > MAX_ARCHIVE_ENTRIES) {
throw new IOException("Plugin package has too many files");
validateArchiveEntryCount(entryCount);
// CLI zipSync 只生成文件 entry显式目录 entry 不属于 v1 协议
// 与服务端保持一致拒绝避免目录绕过 entry 数量和路径检查
if (entry.isDirectory()) {
throw new IOException("Plugin package directory entries are not allowed");
}
String name = entry.getName();
validateArchiveEntry(moduleId, name);
@ -108,8 +111,11 @@ final class XuqmPluginPackageStager {
throw new IOException("Unable to create package directory " + parent);
}
try (FileOutputStream output = new FileOutputStream(destination)) {
long entryUncompressedBytes = 0L;
int count;
while ((count = input.read(buffer)) != -1) {
entryUncompressedBytes += count;
validateEntryUncompressedSize(entryUncompressedBytes);
uncompressedBytes += count;
if (uncompressedBytes > MAX_UNCOMPRESSED_BYTES) {
throw new IOException("Plugin package exceeds the uncompressed size limit");
@ -122,6 +128,18 @@ final class XuqmPluginPackageStager {
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(
String moduleId,
File packageDirectory,
@ -199,6 +217,9 @@ final class XuqmPluginPackageStager {
requireEqual(candidate, manifest, "moduleId");
requireEqual(candidate, manifest, "type");
requireEqual(candidate, manifest, "version");
requireEqual(candidate, manifest, "buildId");
requireEqual(candidate, manifest, "bundleFormat");
requireEqual(candidate, manifest, "bundleSha256");
requireEqual(candidate, manifest, "appVersionRange");
requireOptionalEqual(candidate, manifest, "builtAgainstNativeBaselineId");
requireOptionalEqual(candidate, manifest, "commonVersionRange");

查看文件

@ -18,4 +18,18 @@ public final class XuqmPluginPackageStagerTest {
IllegalArgumentException.class,
() -> 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 不在线查询吊销状态
*/
export async function validateReleaseConfig({ projectRoot, packageName, platform }) {
export async function validateReleaseConfig({ projectRoot, packageName }) {
const hostRequire = createRequire(path.join(projectRoot, 'package.json'))
const commonMetro = hostRequire('@xuqm/rn-common/metro')
const configFile = commonMetro.findConfigFile(projectRoot)
@ -15,16 +15,15 @@ export async function validateReleaseConfig({ projectRoot, packageName, platform
const content = readFileSync(configFile, 'utf8').trim()
const resolved = commonMetro.decryptConfigAtBuildTime(content)
const targetPackageName =
packageName ??
(platform === 'ios' ? (resolved.iosBundleId ?? resolved.packageName) : resolved.packageName)
if (!targetPackageName) throw new Error('A package identity is required for Release validation')
if (typeof packageName !== 'string' || packageName.trim() === '') {
throw new Error('A package identity is required for Release validation')
}
const response = await fetch(
`${String(resolved.serverUrl).replace(/\/$/, '')}/api/sdk/build/config/validate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, packageName: targetPackageName }),
body: JSON.stringify({ content, packageName }),
},
)
if (!response.ok) {

查看文件

@ -26,6 +26,7 @@ export function readReleasePackageIdentity(packageFile, expected) {
throw new Error('plugin package manifest is invalid JSON')
}
const packageName = requiredString(manifest, 'packageName')
const moduleId = requiredString(manifest, 'moduleId')
const platform = requiredString(manifest, 'platform')
const moduleVersion = requiredString(manifest, 'version')
@ -41,6 +42,11 @@ export function readReleasePackageIdentity(packageFile, expected) {
if (buildId === 'development') {
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) {
throw new Error(
`plugin package moduleId mismatch: expected ${expected.moduleId}, got ${moduleId}`,
@ -83,6 +89,7 @@ export function readReleasePackageIdentity(packageFile, expected) {
return {
manifest,
packageName,
moduleId,
platform,
moduleVersion,

查看文件

@ -11,6 +11,7 @@ function createPackage(root, overrides = {}, bundle = 'bundle') {
const bundleBytes = strToU8(bundle)
const manifest = {
schemaVersion: 1,
packageName: 'com.example.host',
moduleId: 'app',
platform: 'android',
version: '1.0.1',
@ -37,6 +38,7 @@ function createPackage(root, overrides = {}, bundle = 'bundle') {
}
const expected = {
packageName: 'com.example.host',
moduleId: 'app',
platform: 'android',
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-'))
try {
const identity = readReleasePackageIdentity(createPackage(root), expected)
assert.equal(identity.packageName, 'com.example.host')
assert.equal(identity.buildId, 'build-20260726')
assert.equal(identity.bundleFormat, 'hermes-bytecode')
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', () => {
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-package-invalid-'))
try {

查看文件

@ -27,6 +27,18 @@ export function readConfig(root = process.cwd()) {
export function validateConfig(config, root = process.cwd()) {
const errors = []
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 (typeof config.pluginVersion !== 'string' || !semver.valid(config.pluginVersion)) {
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)) {
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) {
errors.push('modules must contain at least one module')
return errors
@ -103,6 +118,22 @@ export function validateConfig(config, root = process.cwd()) {
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()) {
const packageFile = path.join(root, 'package.json')
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 path from 'node:path'
import test from 'node:test'
import { resolveVersionedModules, validateConfig } from './xuqm-config.mjs'
import { resolveHostPackageId, resolveVersionedModules, validateConfig } from './xuqm-config.mjs'
function fixture() {
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 {
const config = {
appId: 'example',
packageName: 'com.example.host',
mainModuleName: 'Example',
pluginVersion: '1.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 })
const base = {
appId: 'example',
packageName: 'com.example.host',
mainModuleName: 'Example',
pluginVersion: '1.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 })
}
})
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,
parseModuleOptions,
readConfig,
resolveHostPackageId,
resolveVersionedModules,
selectModules,
validateConfig,
@ -47,23 +48,10 @@ function writeJson(file, value) {
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`)
}
function androidApplicationId() {
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) {
async function validateReleaseConfigOnline(config, platform) {
await validateReleaseConfig({
projectRoot: root,
packageName: platform === 'android' ? androidApplicationId() : undefined,
platform,
packageName: resolveHostPackageId(config, platform),
})
}
@ -164,6 +152,7 @@ function init() {
writeJson(configFile, {
schemaVersion: 3,
appId: pkg.name ?? 'react-native-app',
packageName: pkg.name ?? 'react-native-app',
mainModuleName: pkg.name ?? 'ReactNativeApp',
pluginVersion: '1.0.0',
appVersionRange: compatibleAppRange(pkg.version ?? '1.0.0'),
@ -334,6 +323,7 @@ function buildEntryFile(module, platform) {
function build(platform, optionArgs = []) {
assertPlatform(platform)
const config = doctor()
resolveHostPackageId(config, platform)
const versioned = resolveVersionedModules(config, root, process.env.XUQM_APP_VERSION)
const versionedConfig = { ...config, modules: versioned.modules }
const modules = parseSelectedModules(versionedConfig, optionArgs)
@ -436,6 +426,7 @@ function createModulePackage(config, platform, module, appVersion, nativeBaselin
})
const manifest = {
schemaVersion: 1,
packageName: resolveHostPackageId(config, platform),
moduleId: module.id,
type: module.type,
platform,
@ -636,7 +627,8 @@ async function packageApp(platform, optionArgs) {
fail(error instanceof Error ? error.message : String(error))
}
const { apk, bugCollectMode } = packageOptions
await validateReleaseConfigOnline(platform)
const config = doctor()
await validateReleaseConfigOnline(config, platform)
const buildIdentity = createAndroidReleaseIdentity({ projectRoot: root })
const androidDirectory = path.join(root, 'android')
const wrapper = path.join(

查看文件

@ -52,6 +52,8 @@ test('embed creates a complete versioned manifest from a host fixture', () => {
JSON.stringify({
schemaVersion: 3,
appId: 'fixture.app',
packageName: 'com.example.fixture',
iosBundleId: 'com.example.fixture.ios',
mainModuleName: 'Fixture',
pluginVersion: '1.0.0',
appVersionRange: '>=7.2.14 <8.0.0',
@ -119,6 +121,7 @@ test('Android Hermes hosts compile plugin bundles to bytecode', () => {
JSON.stringify({
schemaVersion: 3,
appId: 'fixture.app',
packageName: 'com.example.fixture',
mainModuleName: 'Fixture',
pluginVersion: '1.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({
schemaVersion: 3,
appId: 'fixture.app',
packageName: 'com.example.fixture',
mainModuleName: 'Fixture',
pluginVersion: '1.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({
schemaVersion: 3,
appId: 'fixture.app',
packageName: 'com.example.fixture',
iosBundleId: 'com.example.fixture.ios',
mainModuleName: 'Fixture',
pluginVersion: '1.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.forceColor, '0')
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 {
rmSync(root, { recursive: true, force: true })
}
@ -384,6 +409,7 @@ test('building one buz first rebuilds startup and common module maps', () => {
JSON.stringify({
schemaVersion: 3,
appId: 'fixture.app',
packageName: 'com.example.fixture',
mainModuleName: 'Fixture',
pluginVersion: '1.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']))
assert.equal(packageManifest.schemaVersion, 1)
assert.equal(packageManifest.packageName, 'com.example.fixture')
assert.equal(packageManifest.moduleId, 'common')
assert.equal(packageManifest.bundleFormat, 'javascript')
assert.deepEqual(packageManifest.assets, [])

查看文件

@ -10,6 +10,7 @@ import {
bundleOutputDirectory,
parseModuleOptions,
readConfig,
resolveHostPackageId,
resolveVersionedModules,
selectModules,
} from './xuqm-config.mjs'
@ -53,6 +54,7 @@ if (!['platform', 'disabled'].includes(bugCollectMode)) {
}
const config = readConfig(root)
const hostPackageId = resolveHostPackageId(config, platform)
const versioned = resolveVersionedModules(config, root, process.env.XUQM_APP_VERSION)
const selectedModules = selectModules({ ...config, modules: versioned.modules }, moduleIds)
if (moduleIds.length > 0 && selectedModules.some(module => module.type === 'startup')) {
@ -82,8 +84,7 @@ function apiHeaders() {
async function validateReleaseConfigOnline() {
await validateReleaseConfig({
projectRoot: root,
packageName: config.packageName,
platform,
packageName: hostPackageId,
})
}
@ -94,6 +95,7 @@ function releasePackage(module) {
)
if (!existsSync(file)) throw new Error(`plugin package is missing: ${file}`)
const identity = readReleasePackageIdentity(file, {
packageName: hostPackageId,
moduleId: module.id,
platform,
moduleVersion: module.moduleVersion,
@ -116,7 +118,7 @@ async function uploadBundle(module, release, note) {
form.append('commonVersionRange', identity.commonVersionRange ?? '')
form.append('minNativeApiLevel', String(identity.minNativeApiLevel))
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`)
const response = await fetch(`${serverUrl}/api/v1/rn/upload`, {
method: 'POST',

查看文件

@ -27,6 +27,9 @@ export interface NativeReleaseModule {
/** Base64-encoded .xuqm.zip bytes; never decode Hermes bytecode as text. */
archiveBase64: string
sha256: string
buildId: string
bundleFormat: string
bundleSha256: string
appVersionRange: string
builtAgainstNativeBaselineId?: string
commonVersionRange?: string

查看文件

@ -11,6 +11,10 @@ import {
sha256Hex,
type DownloadProgress,
} from '@xuqm/rn-common'
import {
verifySignedPluginManifest,
type SignedPluginManifest,
} from '@xuqm/rn-common/internal-security'
import { NativeBundle, type NativeReleaseModule } from './NativeBundle'
import { NativeAppUpdate } from './NativeAppUpdate'
import { getAppVersionCode, getAppVersionName, _devSetAppVersion } from './NativeVersion'
@ -22,6 +26,8 @@ import {
type ReleaseSetPlan,
} from './releaseSet'
import { shouldSkipUpdateForLogin } from './updateLoginPolicy'
import { assertUpdateEnabled, UpdateDisabledError } from './updateAvailability'
import { createReleaseSetCheckBody } from './releaseSetRequest'
export interface PluginRegistration {
moduleId: string
@ -68,16 +74,23 @@ interface RawAppUpdateInfo extends Omit<AppUpdateInfo, 'sha256'> {
}
interface RawPluginReleaseCandidate {
appKey: string
packageName: string
platform: 'ANDROID' | 'IOS'
moduleId: string
type: PluginModuleType
version?: string
latestVersion?: string
version: string
downloadUrl: string
sha256: string
appVersionRange: string
builtAgainstNativeBaselineId?: string
commonVersionRange?: string
minNativeApiLevel?: number
builtAgainstNativeBaselineId: string
buildId: string
bundleFormat: 'hermes-bytecode' | 'javascript'
bundleSha256: string
commonVersionRange?: string | null
minNativeApiLevel: number
keyId: string
signature: string
note?: string
forceUpdate?: boolean
changeLog?: string
@ -114,6 +127,18 @@ function isLoginRequiredWithoutSession(): boolean {
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> {
const previousCacheKey = sessionCacheKey(UPDATE_APP_CACHE_KEY)
activeSessionAbort.abort()
@ -226,15 +251,21 @@ async function installedModule(registration: PluginRegistration): Promise<Instal
}
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 {
appKey: info.appKey,
packageName: info.packageName,
platform: info.platform,
moduleId: info.moduleId,
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,
sha256: info.sha256,
commonVersionRange: info.commonVersionRange,
commonVersionRange: info.commonVersionRange ?? undefined,
appVersionRange: info.appVersionRange,
builtAgainstNativeBaselineId: info.builtAgainstNativeBaselineId,
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 {
if (!/^[a-f0-9]{64}$/i.test(expected)) {
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> {
await awaitInitialization()
requireUpdateEnabled()
if (isLoginRequiredWithoutSession()) {
return { needsUpdate: false, requiresLogin: true, skippedReason: 'LOGIN_REQUIRED' }
}
@ -378,6 +449,8 @@ export const UpdateSDK = {
updateInfo: AppUpdateInfo,
options: { signal?: AbortSignal; onProgress?: (progress: UpdateDownloadProgress) => void } = {},
): Promise<ArrayBuffer> {
await awaitInitialization()
requireUpdateEnabled()
if (!updateInfo.downloadUrl) throw new Error('[UpdateSDK] App update has no downloadUrl')
const bytes = await downloadBytes(updateInfo.downloadUrl, {
...options,
@ -395,6 +468,8 @@ export const UpdateSDK = {
onProgress?: (progress: UpdateDownloadProgress) => void
} = {},
): Promise<void> {
await awaitInitialization()
requireUpdateEnabled()
if (!updateInfo.downloadUrl || !updateInfo.versionCode) {
throw new Error('[UpdateSDK] App update requires downloadUrl and versionCode')
}
@ -414,6 +489,7 @@ export const UpdateSDK = {
options: { signal?: AbortSignal; timeoutMs?: number } = {},
): Promise<ReleaseSetPlan | null> {
await awaitInitialization()
requireUpdateEnabled()
if (isLoginRequiredWithoutSession()) return null
const target = requireRegistration(targetModuleId)
if (target.type === 'common') {
@ -431,19 +507,29 @@ export const UpdateSDK = {
throw new Error('[UpdateSDK] Exactly one common module must be registered')
}
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 nativeApiLevel = await NativeBundle.getNativeApiLevel()
const nativeBaselineId = registeredNativeBaselineId(registrations) ?? ''
const appVersion = requireAppVersion('update checks')
const raw = await apiRequest<RawPluginReleaseSet>('/api/v1/rn/release-set/check', {
method: 'POST',
skipAuth: true,
signal: currentSessionSignal(options.signal),
timeoutMs: options.timeoutMs,
body: {
body: createReleaseSetCheckBody({
appKey: config.appKey,
targetModuleId,
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
platform,
userId: userId || undefined,
appVersion,
nativeApiLevel,
nativeBaselineId,
installedModules: installed,
},
}),
})
const allowed = new Set([common.moduleId, targetModuleId])
const candidates = (raw.modules ?? raw.candidates ?? []).map(candidate => {
@ -453,20 +539,28 @@ export const UpdateSDK = {
)
}
const registration = requireRegistration(candidate.moduleId)
return asCandidate({
const normalized = asCandidate({
...candidate,
type: registration.type,
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({
installed,
candidates,
nativeApiLevel: await NativeBundle.getNativeApiLevel(),
nativeBaselineId: registeredNativeBaselineId(registrations),
appVersion: getAppVersionName() ?? '0.0.0',
nativeApiLevel,
nativeBaselineId,
appVersion,
releaseId: raw.releaseId,
})
if (plan && (await readQuarantinedReleases())[targetModuleId] === plan.releaseId) {
@ -483,7 +577,21 @@ export const UpdateSDK = {
onProgress?: (moduleId: string, progress: UpdateDownloadProgress) => void
} = {},
): Promise<ReleaseSetPlan | null> {
await awaitInitialization()
requireUpdateEnabled()
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 installed = await Promise.all(
registrations.map(registration => installedModule(registration)),
@ -498,7 +606,7 @@ export const UpdateSDK = {
candidates: plan.modules,
nativeApiLevel: await NativeBundle.getNativeApiLevel(),
nativeBaselineId: registeredNativeBaselineId(registrations),
appVersion: getAppVersionName() ?? '0.0.0',
appVersion: requireAppVersion('installation'),
releaseId: plan.releaseId,
})
if (!verifiedPlan) return null
@ -519,6 +627,9 @@ export const UpdateSDK = {
version: module.version,
archiveBase64: bytesToBase64(archive),
sha256: module.sha256,
buildId: module.buildId,
bundleFormat: module.bundleFormat,
bundleSha256: module.bundleSha256,
commonVersionRange: module.commonVersionRange,
appVersionRange: module.appVersionRange!,
builtAgainstNativeBaselineId: module.builtAgainstNativeBaselineId,
@ -632,7 +743,14 @@ export const UpdateSDK = {
/** @internal 共享登录态变化时取消旧灰度授权,并为新用户自动补检一次。 */
async _handleSessionChange(loggedIn: boolean): Promise<void> {
await clearSessionUpdateState()
if (!loggedIn || !getConfig().updateRequiresLogin || getConfig().debug) return
if (
!loggedIn ||
!getConfig().updateEnabled ||
!getConfig().updateRequiresLogin ||
getConfig().debug
) {
return
}
if (automaticLoginCheckGeneration === sessionGeneration) return
automaticLoginCheckGeneration = sessionGeneration
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())
_registerBundleIdentityProvider('rn-update', resolveCurrentBundleIdentity)
export { StaleReleaseSetError, UpdateSDK } from './UpdateSDK'
export { StaleReleaseSetError, UpdateDisabledError, UpdateSDK } from './UpdateSDK'
export type {
PluginRegistration,
AppUpdateInfo,

查看文件

@ -14,7 +14,16 @@ export interface 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
/** 下载归档文件的 SHA-256;归档内 bundle 另由已签名 bundleSha256 约束。 */
sha256: string
forceUpdate?: boolean
changeLog?: string
@ -129,7 +138,15 @@ export function planReleaseSet(options: PlanReleaseSetOptions): ReleaseSetPlan |
for (const module of installed.values()) assertModule(module)
for (const candidate of candidates.values()) {
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(
`${candidate.moduleId} must provide a download URL and SHA-256`,
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(
module: Omit<PluginReleaseCandidate, 'downloadUrl' | 'sha256'>,
module: Pick<PluginReleaseCandidate, 'moduleId' | 'type' | 'version'> &
Partial<Omit<PluginReleaseCandidate, 'moduleId' | 'type' | 'version'>>,
): PluginReleaseCandidate {
return {
appKey: 'app-key',
packageName: 'com.example.app',
platform: 'ANDROID',
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`,
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 自动生成的
查看文件

@ -66,6 +66,9 @@ importers:
'@noble/ciphers':
specifier: 2.2.0
version: 2.2.0
'@noble/curves':
specifier: 2.2.0
version: 2.2.0
'@noble/hashes':
specifier: 2.2.0
version: 2.2.0
@ -431,6 +434,10 @@ packages:
resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==}
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':
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
engines: {node: '>= 20.19.0'}
@ -671,14 +678,6 @@ packages:
cpu: [x64]
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:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
@ -1862,6 +1861,10 @@ snapshots:
'@noble/ciphers@2.2.0': {}
'@noble/curves@2.2.0':
dependencies:
'@noble/hashes': 2.2.0
'@noble/hashes@2.2.0': {}
'@nozbe/lokijs@1.5.12-wmelon6': {}
@ -2055,17 +2058,6 @@ snapshots:
'@typescript/typescript-win32-x64@7.0.2':
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:
dependencies:
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/file-name": ["./packages/common/src/fileName.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/version": ["./packages/common/src/version.ts"],
"@xuqm/rn-im": ["./packages/im/src"],