feat(update): secure signed plugin release sets
这个提交包含在:
父节点
3d2134e08a
当前提交
daa96302dd
@ -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
普通文件
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 {
|
||||
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
普通文件
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
自动生成的
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"],
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户