From cf1068c2815ed994f57aba93c3ffd83d3fe44fc1 Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Wed, 23 Sep 2026 10:04:03 +0800 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20=E5=85=BC=E5=AE=B9=E9=94=AE=20r?= =?UTF-8?q?untimeVersion=20=E6=94=B9=E5=90=8D=20hostVersion=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E9=99=90=E5=AE=9A=E4=B8=BA=E6=AD=A3=E6=95=B4=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M5.1。tinyui 里 runtime 已指运行时模块(tinyui-core / tinyui-native、 manifest 的 runtime 字段)与 JS 引擎,而这个值恰恰不是它们的版本,是 宿主对"给页面提供了什么"的声明。 - manifest 字段、BuildManifest、Updates 构造参数、Mismatch 枚举项、 CLI 的 --host-version、文档全部改名;文档注明即 Expo 的 runtimeVersion - 正整数校验放在 bundle、publish、releases、Updates 构造四处:App 版本 号(1.2.0)是这个名字最可能被填错的值 - 与服务端共用的签名夹具因字段改名换钥重签,Android host 与 iOS 模拟器 两端验签都过 - 文档里的"契约快照"改称 tinyui-host/.txt Co-Authored-By: Claude Opus 5.5 (1M context) --- compose/api/compose.klib.api | 4 +- .../kotlin/app/tinyui/BuildManifest.kt | 4 +- .../kotlin/app/tinyui/BundleTest.kt | 10 +-- docs/README.md | 2 +- docs/adr-006-hot-updates.md | 46 +++++++------- docs/updates.md | 62 +++++++++---------- packages/cli/src/admin.ts | 8 +-- packages/cli/src/bin.ts | 41 ++++++------ packages/cli/src/bundle.ts | 19 +++--- packages/cli/src/index.ts | 2 +- packages/cli/src/publish.ts | 31 +++++----- packages/cli/test/admin.test.ts | 2 +- packages/cli/test/bundle.test.ts | 60 +++++++++--------- packages/cli/test/helpers.ts | 4 +- packages/cli/test/publish.test.ts | 8 +-- .../kotlin/app/tinyui/sample/App.kt | 2 +- updates/api/updates.klib.api | 6 +- .../kotlin/app/tinyui/updates/Events.kt | 2 +- .../kotlin/app/tinyui/updates/Updates.kt | 24 +++---- .../kotlin/app/tinyui/updates/Fixture.kt | 8 +-- .../kotlin/app/tinyui/updates/UpdatesTest.kt | 32 +++++----- 21 files changed, 194 insertions(+), 183 deletions(-) diff --git a/compose/api/compose.klib.api b/compose/api/compose.klib.api index 86f2f37..1af914e 100644 --- a/compose/api/compose.klib.api +++ b/compose/api/compose.klib.api @@ -247,6 +247,8 @@ final class app.tinyui/BuildManifest { // app.tinyui/BuildManifest|null[0] final fun (): kotlin.collections/Map // app.tinyui/BuildManifest.files.|(){}[0] final val hashes // app.tinyui/BuildManifest.hashes|{}hashes[0] final fun (): kotlin.collections/Map // app.tinyui/BuildManifest.hashes.|(){}[0] + final val hostVersion // app.tinyui/BuildManifest.hostVersion|{}hostVersion[0] + final fun (): kotlin/String? // app.tinyui/BuildManifest.hostVersion.|(){}[0] final val name // app.tinyui/BuildManifest.name|{}name[0] final fun (): kotlin/String // app.tinyui/BuildManifest.name.|(){}[0] final val pages // app.tinyui/BuildManifest.pages|{}pages[0] @@ -257,8 +259,6 @@ final class app.tinyui/BuildManifest { // app.tinyui/BuildManifest|null[0] final fun (): kotlin/String // app.tinyui/BuildManifest.publicKey.|(){}[0] final val runtime // app.tinyui/BuildManifest.runtime|{}runtime[0] final fun (): kotlin.collections/List // app.tinyui/BuildManifest.runtime.|(){}[0] - final val runtimeVersion // app.tinyui/BuildManifest.runtimeVersion|{}runtimeVersion[0] - final fun (): kotlin/String? // app.tinyui/BuildManifest.runtimeVersion.|(){}[0] final val version // app.tinyui/BuildManifest.version|{}version[0] final fun (): kotlin/String // app.tinyui/BuildManifest.version.|(){}[0] diff --git a/compose/src/commonMain/kotlin/app/tinyui/BuildManifest.kt b/compose/src/commonMain/kotlin/app/tinyui/BuildManifest.kt index a37c46f..ed5f222 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/BuildManifest.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/BuildManifest.kt @@ -21,7 +21,7 @@ class BuildManifest( val protocol: Int, val hashes: Map, /** Present only in a manifest `tinyui bundle` wrote: the embedded package has none. */ - val runtimeVersion: String? = null, + val hostVersion: String? = null, ) { fun buildId(module: String): String = buildIds[module] ?: "" @@ -51,7 +51,7 @@ class BuildManifest( engine = string("engine"), protocol = root["protocol"]?.jsonPrimitive?.intOrNull ?: 0, hashes = strings("hashes"), - runtimeVersion = root["runtimeVersion"]?.jsonPrimitive?.contentOrNull, + hostVersion = root["hostVersion"]?.jsonPrimitive?.contentOrNull, ) } } diff --git a/compose/src/commonTest/kotlin/app/tinyui/BundleTest.kt b/compose/src/commonTest/kotlin/app/tinyui/BundleTest.kt index a022ccc..3ccf96b 100644 --- a/compose/src/commonTest/kotlin/app/tinyui/BundleTest.kt +++ b/compose/src/commonTest/kotlin/app/tinyui/BundleTest.kt @@ -51,7 +51,7 @@ class BundleTest { assertEquals("shop", bundle.name) assertEquals("20260922T090000Z-3f2a1c", bundle.manifest.version) assertEquals(1, bundle.manifest.protocol) - assertNull(bundle.manifest.runtimeVersion, "an embedded manifest has no runtimeVersion") + assertNull(bundle.manifest.hostVersion, "an embedded manifest has no hostVersion") val page = bundle.page("shop/home") assertEquals("shop/home", page.module.name) assertEquals("HOME", page.module.bytecode.decodeToString()) @@ -93,12 +93,12 @@ class BundleTest { @Test fun manifestWithoutPackageIdentityIsRejected() { assertFailsWith { BuildManifest.parse("""{"runtime":[],"pages":[],"files":{},"buildIds":{}}""") } - val bundled = BuildManifest.parse(manifest.removeSuffix("}") + ""","runtimeVersion":"1"}""") - assertEquals("1", bundled.runtimeVersion) + val bundled = BuildManifest.parse(manifest.removeSuffix("}") + ""","hostVersion":"1"}""") + assertEquals("1", bundled.hostVersion) assertFailsWith("a JSON null is not an identity") { BuildManifest.parse("""{"runtime":[],"pages":[],"files":{},"buildIds":{},"name":null,"publicKey":null}""") } - val nulls = BuildManifest.parse(manifest.removeSuffix("}") + ""","runtimeVersion":null}""") - assertNull(nulls.runtimeVersion) + val nulls = BuildManifest.parse(manifest.removeSuffix("}") + ""","hostVersion":null}""") + assertNull(nulls.hostVersion) } } diff --git a/docs/README.md b/docs/README.md index 308802c..f96fbcc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,7 +44,7 @@ JS 桥 Kotlin | [ADR-003](./adr-003-kotlin-node-tree-and-registry.md) | Kotlin 侧节点表与组件注册 | 已定(2026-09-15) | 节点即重组单元;JS 线程直接写快照状态、主线程只重组;App 级注册表 + 清单下发;prop / event schema 写入时转换 | | [ADR-004](./adr-004-events-and-input-ownership.md) | 事件与输入状态归属 | 已定(2026-09-15) | 事件三分(离散 / 流式输入 / 连续),60 fps 状态留 Kotlin;新增 `x` 命令 op(ref + cmd);文本框 initial prop + 命令 + 事件,不受控回写 | | [ADR-005](./adr-005-engine.md) | JS 引擎选型 | 已定(2026-09-15) | MicroQuickJS → QuickJS(ES2025,经 quickjs-kmp);原生 ESM 必选;不降级 ES5;热下发不在本期(2026-09-18 起见 ADR-006) | -| [ADR-006](./adr-006-hot-updates.md) | 热下发 | 已定(2026-09-18,2026-09-19 补服务端与签名、改多包模型) | App 由 N≥1 个包组成,包名进模块名与 URL,各包独立发布回退;整包原子、下次启动生效;宿主 `runtimeVersion` 为兼容键;内置包是地板,下发页失败即回退并拉黑;`tinyui-updates` 独立 artifact 不做网络 / 调度 / UI;投递两次 GET、发布 PUT;manifest 签名 ECDSA P-256,密钥按包、公钥随包进 manifest;服务端 `tinyui-updates-server` 独立开源仓、CF 为主可私有化,托管 `updates.tinyui.app`;契约见 [updates.md](./updates.md),实施计划见 [updates-plan.md](./updates-plan.md) | +| [ADR-006](./adr-006-hot-updates.md) | 热下发 | 已定(2026-09-18,2026-09-19 补服务端与签名、改多包模型) | App 由 N≥1 个包组成,包名进模块名与 URL,各包独立发布回退;整包原子、下次启动生效;宿主 `hostVersion` 为兼容键;内置包是地板,下发页失败即回退并拉黑;`tinyui-updates` 独立 artifact 不做网络 / 调度 / UI;投递两次 GET、发布 PUT;manifest 签名 ECDSA P-256,密钥按包、公钥随包进 manifest;服务端 `tinyui-updates-server` 独立开源仓、CF 为主可私有化,托管 `updates.tinyui.app`;契约见 [updates.md](./updates.md),实施计划见 [updates-plan.md](./updates-plan.md) | ## 命名 diff --git a/docs/adr-006-hot-updates.md b/docs/adr-006-hot-updates.md index c09ddd1..893e8b4 100644 --- a/docs/adr-006-hot-updates.md +++ b/docs/adr-006-hot-updates.md @@ -1,7 +1,7 @@ -# ADR-006 · 热下发:整包原子、宿主 runtimeVersion 为兼容键、内置包是地板 +# ADR-006 · 热下发:整包原子、宿主 hostVersion 为兼容键、内置包是地板 -- 状态:已定(2026-09-18;2026-09-19 修订:服务端定为开源可私有化的托管服务,签名进 MVP,客户端与服务端分仓;2026-09-19 再修订:多包模型——App 由 N≥1 个包组成,包名进模块名与 URL,公钥进 manifest;2026-09-22 修订:签名覆盖不可变 manifest 的原始字节而非规范化 JSON,指针文件只含 version / rollout / signature;启动时重算 installed 包 sha256,挂载标记作推迟项;2026-09-23 §2.2 补精确匹配与 `>=` 范围的对比,bump 口径进 updates.md §4.1);实现待开 -- 结论:**App 由 N≥1 个包组成,包是所有权单元与分发单元,一个包是一次 `tinyui build` 的完整产物,整包原子生效、下次启动切换,各包独立发布、独立回退;包名进模块名(`subscription/home`)与投递 URL,密钥与发布 token 按包发,验签公钥随包进 manifest、以内置包的为信任锚;兼容键是宿主声明的 `runtimeVersion`,引擎 commit 与 patch 协议号只做校验;内置包永远是地板,下发包失败即回退内置并拉黑;库(独立 artifact `tinyui-updates`)以一组包为单位做校验 / 落盘 / 选择 / 回退,不做网络、调度、UI;服务端协议是两次 GET——可变指针 + 不可变内容,指针背后是静态文件还是动态端点客户端不关心;灰度靠 manifest 里的百分比 + 客户端掷骰;回滚 = 指针换回上一个好包;完整性 = 逐文件 sha256 + manifest 签名(ECDSA P-256,发布方持私钥);服务端参考实现 `tinyui-updates-server` 独立开源仓、Cloudflare 为主、可私有化,托管实例 `updates.tinyui.app`** +- 状态:已定(2026-09-18;2026-09-19 修订:服务端定为开源可私有化的托管服务,签名进 MVP,客户端与服务端分仓;2026-09-19 再修订:多包模型——App 由 N≥1 个包组成,包名进模块名与 URL,公钥进 manifest;2026-09-22 修订:签名覆盖不可变 manifest 的原始字节而非规范化 JSON,指针文件只含 version / rollout / signature;启动时重算 installed 包 sha256,挂载标记作推迟项;2026-09-23 §2.2 补精确匹配与 `>=` 范围的对比,bump 口径进 updates.md §4.1;同日兼容键改名 `hostVersion`,见 updates.md §4.1);实现待开 +- 结论:**App 由 N≥1 个包组成,包是所有权单元与分发单元,一个包是一次 `tinyui build` 的完整产物,整包原子生效、下次启动切换,各包独立发布、独立回退;包名进模块名(`subscription/home`)与投递 URL,密钥与发布 token 按包发,验签公钥随包进 manifest、以内置包的为信任锚;兼容键是宿主声明的 `hostVersion`,引擎 commit 与 patch 协议号只做校验;内置包永远是地板,下发包失败即回退内置并拉黑;库(独立 artifact `tinyui-updates`)以一组包为单位做校验 / 落盘 / 选择 / 回退,不做网络、调度、UI;服务端协议是两次 GET——可变指针 + 不可变内容,指针背后是静态文件还是动态端点客户端不关心;灰度靠 manifest 里的百分比 + 客户端掷骰;回滚 = 指针换回上一个好包;完整性 = 逐文件 sha256 + manifest 签名(ECDSA P-256,发布方持私钥);服务端参考实现 `tinyui-updates-server` 独立开源仓、Cloudflare 为主、可私有化,托管实例 `updates.tinyui.app`** - 契约(manifest 字段、投递与发布协议、签名、客户端状态机、API 面):[updates.md](./updates.md) - 影响:docs/README.md 首段与 ADR-005 决策表的"热下发不在本期"改为指向本文;roadmap D 组该项转入实现;build-chain.md §2 的模块名加包名前缀、manifest 字段扩展、`tinyui.config.json`;app-model.md 的路由键与跨包约定;ADR-005 §4 的模块名规则 @@ -36,32 +36,32 @@ ADR-005 把热下发划出当期,条件是"内核稳定后另立 ADR"。Trendi ### 2.2 兼容键 -| | 宿主声明 `runtimeVersion`(Expo 规则) | 多维匹配(引擎 commit × tinyui 版本 × 宿主 schema 哈希) | semver 范围 | +| | 宿主声明 `hostVersion`(Expo 的 `runtimeVersion` 规则) | 多维匹配(引擎 commit × tinyui 版本 × 宿主 schema 哈希) | semver 范围 | |---|---|---|---| -| 规则 | 宿主承诺"同 runtimeVersion 下任何包都能跑",改了宿主组件 / 能力 / 升了 tinyui 就 bump | 服务端按多维求交 | 包声明 `>=1.2 <2` | +| 规则 | 宿主承诺"同 hostVersion 下任何包都能跑",改了宿主组件 / 能力 / 升了 tinyui 就 bump | 服务端按多维求交 | 包声明 `>=1.2 <2` | | 谁负责判断 | 人(发版时) | 服务端逻辑 | 客户端解析 | | 静态托管可行 | 是(作路径) | 否 | 勉强 | -选 runtimeVersion,它同时是服务端目录名。`engine`(引擎 commit)与 `protocol` 写进 manifest 只做校验,防"发错目录"这种人为错误,不参与匹配逻辑。 +选 hostVersion,它同时是服务端目录名。`engine`(引擎 commit)与 `protocol` 写进 manifest 只做校验,防"发错目录"这种人为错误,不参与匹配逻辑。 兼容键回答的是"这一版页面能发给哪些已装好的 App"。页面依赖宿主给它的东西——宿主组件、宿主能力、tinyui 本身——而宿主只能随 App 发版改,页面随时可以热下发,两边的新旧必然错开。以一个订阅页为例: -| App | 宿主给页面的东西 | runtimeVersion | +| App | 宿主给页面的东西 | hostVersion | |---|---|---| | 1.0 | `checkout.start` | 1 | | 1.1 | 新增 `coupon.apply` | 2 | | 1.2 | 同 1.1(只修了宿主内部的崩溃) | 2 | | 2.0 | `checkout.start` 改名为 `checkout.begin` | 3 | -调用 `coupon.apply` 的新订阅页发到 rv 2:1.1、1.2 收到;1.0 收不到,继续用它的内置页。 +调用 `coupon.apply` 的新订阅页发到 hostVersion 2:1.1、1.2 收到;1.0 收不到,继续用它的内置页。 **不按 App 版本号**:1.2 与 1.1 给页面的东西相同。按版本号分,每改一次页面就要给每个 App 版本各发一份,否则没升级的用户收不到;而 App 版本号的变化大多与页面无关。 -**不用 `>=` 范围**(页面声明最低 App 版本,CodePush 的 `--targetBinaryVersion` 属此类)。在"宿主新增能力"这种情形下它和 rv 结果相同,还省掉了 bump,但另有三处不成立: +**不用 `>=` 范围**(页面声明最低 App 版本,CodePush 的 `--targetBinaryVersion` 属此类)。在"宿主新增能力"这种情形下它和 hostVersion 结果相同,还省掉了 bump,但另有三处不成立: -1. **宿主删或改东西时**:4 月发的页面声明 `>= 1.0`,会自动匹配到 2.0,去调已经改名的 `checkout.start`。要写上界 `< 2.0`,可写范围的是 4 月写页面的人,知道 2.0 改了什么的是后来改宿主的人。rv 让改宿主的人声明,旧页面自动到不了新宿主。 -2. **一个 channel 会同时有多个"当前版本"**:1.0 该拿 `>= 1.0` 的最新一版,1.1 起该拿 `>= 1.1` 的。投递就不再是每个 channel 一个指针的静态路径,要么服务端按请求求解,要么客户端拉全表自己挑;把共享同一个当前版本的 App 版本归成一组,得到的就是 rv。 -3. **App 版本号跨平台不对齐**:Android 与 iOS 的 1.1 未必提供同样的能力。rv 是宿主代码对自身的声明,与发版号无关。 +1. **宿主删或改东西时**:4 月发的页面声明 `>= 1.0`,会自动匹配到 2.0,去调已经改名的 `checkout.start`。要写上界 `< 2.0`,可写范围的是 4 月写页面的人,知道 2.0 改了什么的是后来改宿主的人。hostVersion 让改宿主的人声明,旧页面自动到不了新宿主。 +2. **一个 channel 会同时有多个"当前版本"**:1.0 该拿 `>= 1.0` 的最新一版,1.1 起该拿 `>= 1.1` 的。投递就不再是每个 channel 一个指针的静态路径,要么服务端按请求求解,要么客户端拉全表自己挑;把共享同一个当前版本的 App 版本归成一组,得到的就是 hostVersion。 +3. **App 版本号跨平台不对齐**:Android 与 iOS 的 1.1 未必提供同样的能力。hostVersion 是宿主代码对自身的声明,与发版号无关。 `>=` 成立的前提是宿主只加不删不改——微信小程序的"最低基础库版本"就建立在基础库的这条承诺上。要求每个宿主永久保留旧能力(换了支付渠道也得留着旧入口)代价过重,所以取精确匹配,与 Expo 的 `runtimeVersion` 一致。什么变化必须 bump、漏 bump 由谁拦,见 [updates.md](./updates.md) §4.1。 @@ -87,7 +87,7 @@ ADR-005 把热下发划出当期,条件是"内核稳定后另立 ADR"。Trendi | 托管 SaaS | EAS Update / App Center CodePush / Shorebird | 各绑各的客户端;CodePush 2025 已退役。排除 | | **两次 GET,指针与内容分离** | 客户端只 `fetch(相对路径)`:一次拿指针 `current.json`(可变,`no-store`,约 100 字节),有新版本且中签时再拿 `/manifest.json` 与 `/`(不可变,`immutable`) | 静态目录是最小实现,动态端点是同协议下的服务端升级,客户端不改 | -选最后一种。库只定义"客户端会请求什么路径",不定义"服务端怎么决定"——这是 §2.4 在服务端的投影。请求不带任何设备 / 版本信息;app 与渠道(staging / production)折在 base URL 里,库只知道包名(从内置 manifest 读)与 runtimeVersion,没有 app / channel 概念;定向发布不做,"能不能跑"归 runtimeVersion,"想不想给"要做就 bump runtimeVersion 或宿主在自己的 `fetch` 里加 header 让服务端判,库不知情。 +选最后一种。库只定义"客户端会请求什么路径",不定义"服务端怎么决定"——这是 §2.4 在服务端的投影。请求不带任何设备 / 版本信息;app 与渠道(staging / production)折在 base URL 里,库只知道包名(从内置 manifest 读)与 hostVersion,没有 app / channel 概念;定向发布不做,"能不能跑"归 hostVersion,"想不想给"要做就 bump hostVersion 或宿主在自己的 `fetch` 里加 header 让服务端判,库不知情。 ### 2.6 服务端实现放哪 @@ -118,7 +118,7 @@ ADR-005 把热下发划出当期,条件是"内核稳定后另立 ADR"。Trendi 逐文件 sha256 锁内容,manifest 签名锁来源。签名进 MVP 而不推迟:租户从托管实例拉可执行代码,实例对租户就是"不受自己控制的第三方",HTTPS + sha256 过不了任何安全评审。私钥只在发布方 CI,服务端登记公钥并在发布时验签,客户端再验一次——token 被盗发不出客户端认的包,服务端被攻破发出的包客户端不认,服务永远只是搬运工。 -客户端的公钥从哪来:宿主逐包传入 vs 写在包的 `tinyui.config.json`、随 build 进 manifest、随内置包进 App。选后者:信任根同样是 App 二进制,但包变成自描述(名字、公钥、rv 都在 manifest 里),宿主接线缩到 `(包列表, rv, dir, installId, fetch)`,多包下"N 把公钥与 N 个包一一对应"这类接线错误没有发生的地方;公钥轮换本来就要 App 发版,与"换内置包"是同一个事件。唯一要写死的规则:**下载的 manifest 里的 `publicKey` 永远不是信任来源**,验签只用内置包的那份。 +客户端的公钥从哪来:宿主逐包传入 vs 写在包的 `tinyui.config.json`、随 build 进 manifest、随内置包进 App。选后者:信任根同样是 App 二进制,但包变成自描述(名字、公钥、hostVersion 都在 manifest 里),宿主接线缩到 `(包列表, hostVersion, dir, installId, fetch)`,多包下"N 把公钥与 N 个包一一对应"这类接线错误没有发生的地方;公钥轮换本来就要 App 发版,与"换内置包"是同一个事件。唯一要写死的规则:**下载的 manifest 里的 `publicKey` 永远不是信任来源**,验签只用内置包的那份。 被签内容是 `/manifest.json` 的原始字节,不是规范化 JSON。原拟把 `rollout` 留在 manifest 里、签名覆盖去掉它之后的规范化 JSON(RFC 8785 子集),代价是 Node、Kotlin、Workers 三端各实现一份规范化,任一处偏差(非 ASCII 模块名的转义、新增字段类型)即全部客户端拒收全部更新,且只能发 App 修——这是 TUF 早期路线,后来被 DSSE 取代的原因正在于此。签原始字节是 DSSE / JWS / Expo code signing / APT 的通行做法。随之指针与 manifest 分成两个文件:可变的 `current.json` 只含 `version` / `rollout` / `signature`(对应 Sparkle appcast、OCI tag),manifest 进不可变路径。副产品:无更新时只下指针,manifest 可进 CDN 缓存。挡住的是"往指针里放需要签名保护的字段"。 @@ -142,9 +142,9 @@ ADR-005 把热下发划出当期,条件是"内核稳定后另立 ADR"。Trendi - **包名进模块名**:`/`,`subscription/home`。"页面名 = 模块名 = 路由键"在多包下原样成立,任何一侧都不拼接;`pages/` 段去掉——包名已是命名空间,它不再携带信息。包名 `[a-z0-9-]+`,按业务域命名不按团队命名(团队会重组) - **运行时仍放包里**:多包让"A 团队升 `tinyui-core` 不牵动 B 团队"成为刚需,宿主带运行时会把版本矩阵带回来 -- **URL 只放永久身份**:`/////…`。组织 / 租户是计费主体,会改名转让,只在管理面作 app 的可改字段,不进 URL。`channel` 是宿主构建变体决定的 App 级部署环境,排在 `pkg` 前,宿主的 base 仍是一段 `/` -- **密钥与 token 按 (app, pkg)**:A 团队物理上发不出 B 包;`name` 与 `runtimeVersion` 都在签名内,包不可能被发成别的包 -- **服务端内容按 (app, pkg, rv, version) 存一份,channel 只是指针**:staging 验过的同一个 version 晋级到 production 不重传不重签,隔离靠 token 的 channel 范围 +- **URL 只放永久身份**:`/////…`。组织 / 租户是计费主体,会改名转让,只在管理面作 app 的可改字段,不进 URL。`channel` 是宿主构建变体决定的 App 级部署环境,排在 `pkg` 前,宿主的 base 仍是一段 `/` +- **密钥与 token 按 (app, pkg)**:A 团队物理上发不出 B 包;`name` 与 `hostVersion` 都在签名内,包不可能被发成别的包 +- **服务端内容按 (app, pkg, hostVersion, version) 存一份,channel 只是指针**:staging 验过的同一个 version 晋级到 production 不重传不重签,隔离靠 token 的 channel 范围 - **不做跨包共享模块**:包自包含,共用工具代码各编一份;build-chain.md §3 原来留的"App 级共享模块"出口一并砍掉——多包下它会变成一条独立的分发链 - **跨包通信照旧走 Kotlin**,store key 与 events topic 按包名加前缀,params 契约当团队间接口版本管(app-model.md §7) @@ -154,17 +154,17 @@ ADR-005 把热下发划出当期,条件是"内核稳定后另立 ADR"。Trendi |---|---| | 包 | App 由 N≥1 个包组成;一个包 = 一次 `tinyui build` 的完整产物(`runtime/*.bin` + `pages/**/*.bin` + `manifest.json`),整包原子生效,各包独立;不做单页下发、不做跨包共享模块、不做 zip、不做 diff | | 包名与模块名 | 包名 `[a-z0-9-]+` 来自 `tinyui.config.json`;模块名 = 路由键 = `/<相对路径>`,无 `pages/` 段 | -| 兼容键 | 宿主声明的 `runtimeVersion`,作服务端路径;`engine` / `protocol` 只校验 | +| 兼容键 | 宿主声明的 `hostVersion`,作服务端路径;`engine` / `protocol` 只校验 | | 启用规则 | 下发包 `version ≠ installed` 且 `createdAt` 新于内置才装;App 升级带来更新的内置包时自动弃掉 installed;不能用下发把 App 降到比内置更老 | | 生效时机 | 下次进程启动 | | 失败语义 | 下发包的页面报 E2 / E6 → 当场用内置包重挂这一页,整包持久化拉黑并上报;E5 不算失败。启动时重算 installed 包 sha256,不一致同样拉黑。进程级崩溃不在库的回退范围:出口是灰度 + 指针回滚 + "`check()` 先于任何页面挂载"的接入约定,挂载标记见 §4.3。一个 (runtime, page) 对永远来自同一个包;不同页面可以短暂来自不同包(各自独立 Runtime) | -| 库的边界 | `tinyui-updates` 独立 artifact;`Updates(packages: List, …)` 以一组包为单位做校验 / 落盘 / 选择 / 回退;不做网络、调度、UI;宿主给内置包列表、`fetch(path)`、`installId`、存储目录、`runtimeVersion`,不传公钥 | -| 服务端协议 | 指针 + 内容;`///current.json`(`no-store`,只含 `version` / `rollout` / `signature`)+ `////…`(`immutable`,含 `manifest.json`);请求不带参数;base = `//`,组织不进 URL | +| 库的边界 | `tinyui-updates` 独立 artifact;`Updates(packages: List, …)` 以一组包为单位做校验 / 落盘 / 选择 / 回退;不做网络、调度、UI;宿主给内置包列表、`fetch(path)`、`installId`、存储目录、`hostVersion`,不传公钥 | +| 服务端协议 | 指针 + 内容;`///current.json`(`no-store`,只含 `version` / `rollout` / `signature`)+ `////…`(`immutable`,含 `manifest.json`);请求不带参数;base = `//`,组织不进 URL | | 灰度 | manifest `rollout` 百分比,客户端按 `installId` 掷骰 | | 回滚 | 重发上一个好包;无 `rollBackToEmbedded` | | 完整性 | 逐文件 sha256 + manifest 签名(ECDSA P-256,覆盖 `/manifest.json` 的原始字节,无规范化);密钥按 (app, pkg),发布方持私钥,服务端与客户端各验一次;公钥写在 `tinyui.config.json` 随 build 进 manifest,客户端只信内置包的 | -| CLI | `tinyui.config.json`(`name` / `publicKey` / `pages`);`tinyui build` 的 manifest 加 `name` / `publicKey` / `version` / `createdAt` / `engine` / `protocol` / `hashes`;`tinyui bundle --runtime-version --signing-key` 产出上传目录 `dist/ota///`(`current.json` + `/`);`tinyui publish` 走发布协议;`keys` / `apps` / `packages` / `tokens` / `releases` 子命令是全部管理面 | -| 服务端 | 独立开源仓 `tinyui-updates-server`(MIT,托管前重评):Hono,单 R2 桶,`Storage` 接口后置;内容按 (app, pkg, rv, version) 存,channel 是指针;托管实例 `updates.tinyui.app`,私有化 = 部署同一份代码;没有控制台 | +| CLI | `tinyui.config.json`(`name` / `publicKey` / `pages`);`tinyui build` 的 manifest 加 `name` / `publicKey` / `version` / `createdAt` / `engine` / `protocol` / `hashes`;`tinyui bundle --host-version --signing-key` 产出上传目录 `dist/ota///`(`current.json` + `/`);`tinyui publish` 走发布协议;`keys` / `apps` / `packages` / `tokens` / `releases` 子命令是全部管理面 | +| 服务端 | 独立开源仓 `tinyui-updates-server`(MIT,托管前重评):Hono,单 R2 桶,`Storage` 接口后置;内容按 (app, pkg, hostVersion, version) 存,channel 是指针;托管实例 `updates.tinyui.app`,私有化 = 部署同一份代码;没有控制台 | | 仓边界 | `tinyui-updates` 在主仓 `updates/` 模块,与 core 同版本发;服务端独立仓;协议只写在 updates.md,发布后字段只增不改 | ## 4. 后果 @@ -175,7 +175,7 @@ App Store Review Guidelines 2.5.2 只豁免由 WebKit / JavaScriptCore 执行的 ### 4.2 首批租户 -TrendingAI 与第二个 App 都作为 `updates.tinyui.app` 的 app 接入,各自一个 `app` id、自己 bump 的 `runtimeVersion`;TrendingAI 一个包(`subscription`),第二个 App 按业务线分包,每个包自己的密钥对与 token。宿主后端不需要任何改动,只配 base URL。发布由各包 JS 工程的 CI 完成:`tinyui bundle --signing-key` → `tinyui publish`。内置包与下发包来自同一次 build,`version` 一致(TrendingAI:`pnpm sync` 提交进 App 的那份)。TrendingAI 现有页面模块名从 `pages/…` 改为 `subscription/…`,随 M6 一起改。 +TrendingAI 与第二个 App 都作为 `updates.tinyui.app` 的 app 接入,各自一个 `app` id、自己 bump 的 `hostVersion`;TrendingAI 一个包(`subscription`),第二个 App 按业务线分包,每个包自己的密钥对与 token。宿主后端不需要任何改动,只配 base URL。发布由各包 JS 工程的 CI 完成:`tinyui bundle --signing-key` → `tinyui publish`。内置包与下发包来自同一次 build,`version` 一致(TrendingAI:`pnpm sync` 提交进 App 的那份)。TrendingAI 现有页面模块名从 `pages/…` 改为 `subscription/…`,随 M6 一起改。 ### 4.3 推迟项(记 roadmap D 组) diff --git a/docs/updates.md b/docs/updates.md index c48960f..f2a7262 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -1,6 +1,6 @@ # 热下发:包、投递协议、发布协议与客户端 -- 状态:已定(2026-09-18;2026-09-19 加发布协议、签名、服务端形态;2026-09-19 改为多包模型——App 由 N≥1 个包组成,包名进模块名与 URL,公钥进 manifest,`Updates` 以一组包为单位;2026-09-22 修订:签名覆盖不可变 `manifest.json` 的原始字节,指针文件 `current.json` 只含 `version` / `rollout` / `signature`,启动时重算 installed 包的 sha256);实现依据,实现待开 +- 状态:已定(2026-09-18;2026-09-19 加发布协议、签名、服务端形态;2026-09-19 改为多包模型——App 由 N≥1 个包组成,包名进模块名与 URL,公钥进 manifest,`Updates` 以一组包为单位;2026-09-22 修订:签名覆盖不可变 `manifest.json` 的原始字节,指针文件 `current.json` 只含 `version` / `rollout` / `signature`,启动时重算 installed 包的 sha256;2026-09-23 兼容键 `hostVersion` 改名 `hostVersion` 并限定为正整数);实现依据,实现待开 - 来源:[ADR-006](./adr-006-hot-updates.md);[build-chain.md](./build-chain.md) §2(manifest、模块名、`tinyui.config.json`)、§7(buildId 与 source map) - 四侧:CLI 产出与发布包(`tinyui build` / `bundle` / `publish`);服务端实现投递与发布两组端点(参考实现 `tinyui-updates-server`,托管实例 `updates.tinyui.app`);Kotlin 的 `Bundle`(core 库)与 `Updates`(`app.tinyui:tinyui-updates`) - 协议规范只在本文一处;服务端仓的一致性测试以本文为准,不复制。**发布后字段与端点只增不改,未知字段透传**——这是两个仓能各自演进的前提 @@ -37,23 +37,23 @@ pages/**/*.bin | `engine` | `tinyui build` | 字节码文件头里的引擎 commit(40 位 hex,所有 `.bin` 一致,取第一个) | | `protocol` | `tinyui build` | 所含 `tinyui-core` 的 `PROTOCOL`,读自子路径导出 `tinyui-core/protocol`(只含常量,不在 Node 里执行运行时模块) | | `hashes` | `tinyui build` | 模块名 → 该模块 `.bin` 的 sha256 hex,键与 `files` 一致 | -| `runtimeVersion` | `tinyui bundle` | 发布目标,等于宿主声明值;写入后文件定稿,签名覆盖它的原始字节(§7) | +| `hostVersion` | `tinyui bundle` | 发布目标,等于宿主声明值;写入后文件定稿,签名覆盖它的原始字节(§7) | -签名值与 `rollout` 不在 manifest 里,在指针文件 `current.json`(§1.2)。内置包的 manifest 没有 `runtimeVersion`,也没有指针文件——宿主知道自己的 runtimeVersion,内置包不参与灰度、不验签。 +签名值与 `rollout` 不在 manifest 里,在指针文件 `current.json`(§1.2)。内置包的 manifest 没有 `hostVersion`,也没有指针文件——宿主知道自己的 hostVersion,内置包不参与灰度、不验签。 ### 1.2 `tinyui bundle` ``` -tinyui bundle --runtime-version --signing-key <私钥 PEM> [--rollout

] [--out dist/ota] +tinyui bundle --host-version --signing-key <私钥 PEM> [--rollout

] [--out dist/ota] ``` 读 `tinyui build` 的输出目录,写: ``` -dist/ota///current.json 指针:{ "version", "rollout", "signature" } -dist/ota////manifest.json build 的 manifest + runtimeVersion,写定后不再变 -dist/ota////runtime/*.bin -dist/ota////pages/**/*.bin +dist/ota///current.json 指针:{ "version", "rollout", "signature" } +dist/ota////manifest.json build 的 manifest + hostVersion,写定后不再变 +dist/ota////runtime/*.bin +dist/ota////pages/**/*.bin ``` `current.json` 只有三个字段:`version` 指向哪个目录;`rollout` 0~100 的整数,缺省 100,服务端可改;`signature` 是对 `/manifest.json` 原始字节的签名(§7)。指针里只有投递策略与签名值,没有任何需要签名保护的字段。 @@ -66,11 +66,11 @@ dist/ota////pages/**/*.bin | 路径 | 内容 | 缓存 | |---|---|---| -| `//current.json` | 可变指针 | `Cache-Control: no-store` | -| `///manifest.json` | 不可变,签名覆盖其原始字节 | `Cache-Control: public, max-age=31536000, immutable` | -| `///.bin` | 不可变内容 | 同上 | +| `//current.json` | 可变指针 | `Cache-Control: no-store` | +| `///manifest.json` | 不可变,签名覆盖其原始字节 | `Cache-Control: public, max-age=31536000, immutable` | +| `///.bin` | 不可变内容 | 同上 | -- `pkg` 库从内置 manifest 读,`rv` 宿主给;宿主只拼 base,一个 App 不管几个包都是一个 base URL、一个 `fetch` +- `pkg` 库从内置 manifest 读,`hostVersion` 宿主给;宿主只拼 base,一个 App 不管几个包都是一个 base URL、一个 `fetch` - 请求不带 query、不带自定义 header、不带任何设备信息;宿主的 `fetch` 实现可以自行加 header 或按路径分流到不同来源,库不知情 - 同一 `` 目录下的文件永不改内容(改内容必换 version),CDN 因此不会陈旧 - 无更新时客户端只下载指针(约 100 字节);`manifest.json` 与文件只在有新 version 且中签时才取 @@ -83,12 +83,12 @@ dist/ota////pages/**/*.bin ``` base = https://updates.tinyui.app// -完整路径 = /////current.json +完整路径 = /////current.json ``` 托管实例的指针、release 记录与 token 都存 R2(强一致):发布、回滚、吊销 token 立即生效。代价是 `current.json` 的读取不走 KV 的边缘缓存,延迟略高,对 App 启动时的后台 `check()` 可接受。 -进 URL 的只有永久不变的身份:`app` 由实例管理员分配、全局唯一(§6.3);`channel` 由宿主构建变体决定(`production` / `staging` / `dev`),是 App 级的部署环境而不是包级的;`pkg`、`rv` 见上。**组织 / 租户(计费主体)不进 URL**——它会改名、转让、拆合,只是 app 记录上的一个可改字段。 +进 URL 的只有永久不变的身份:`app` 由实例管理员分配、全局唯一(§6.3);`channel` 由宿主构建变体决定(`production` / `staging` / `dev`),是 App 级的部署环境而不是包级的;`pkg`、`hostVersion` 见上。**组织 / 租户(计费主体)不进 URL**——它会改名、转让、拆合,只是 app 记录上的一个可改字段。 ### 2.2 静态目录 @@ -120,19 +120,19 @@ class LoadedPage(val runtime: RuntimeBundle, val module: PageModule, val sourceM ```kotlin class Updates( packages: List, // 内置包,各自 manifest.name 即包名;重名、engine 与宿主引擎不符 → 构造抛错 - val runtimeVersion: String, + val hostVersion: String, dir: Path, // 宿主给的目录,如 Android filesDir/tinyui、iOS Application Support/tinyui;库内按

// 分 installId: String, // 稳定的安装标识;库不生成、不持久化、不上传 - fetch: suspend (path: String) -> ByteArray, // 宿主用自己的 HTTP 栈;path 是 §2 的相对路径,库已拼好 // 前缀,宿主只拼 base + fetch: suspend (path: String) -> ByteArray, // 宿主用自己的 HTTP 栈;path 是 §2 的相对路径,库已拼好 // 前缀,宿主只拼 base onEvent: (UpdateEvent) -> Unit = {}, ) ``` 验签公钥不由宿主传:每个包的信任锚是它内置 manifest 里的 `publicKey`(§1.1、§7)。单包宿主写 `Updates(listOf(embedded), …)`。 -**`runtimeVersion` 的口径**:宿主仓持有的递增整数字符串(`"1"`、`"2"`……),语义是"这个值下发布的任何包都能在本宿主上跑";一个宿主一个值,挂在它上面的所有包共用。宿主给页面的东西变了就加 1:宿主组件增删或改 schema;宿主能力增删,或改已有能力的参数与行为;升级 tinyui。只改宿主内部实现、其他原生页面、修崩溃,不加。包的 JS 工程发布时由 `tinyui bundle --runtime-version` 取这个值,不自行推导——JS 侧取错只会让包送不到(`releases list` 与客户端事件可见),是安全的方向。 +**`hostVersion` 的口径**(即 Expo 的 `runtimeVersion`;不叫这个名字,是因为 tinyui 里 runtime 已指运行时模块与引擎):宿主仓持有的递增正整数字符串(`"1"`、`"2"`……;`1.2.0` 这类 App 版本号在 CLI、`Updates`、服务端三处都被拒),语义是"这个值下发布的任何包都能在本宿主上跑";一个宿主一个值,挂在它上面的所有包共用。宿主给页面的东西变了就加 1:宿主组件增删或改 schema;宿主能力增删,或改已有能力的参数与行为;升级 tinyui。只改宿主内部实现、其他原生页面、修崩溃,不加。包的 JS 工程发布时由 `tinyui bundle --host-version` 取这个值,不自行推导——JS 侧取错只会让包送不到(`releases list` 与客户端事件可见),是安全的方向。 -漏加的后果是旧宿主收到跑不了的页面(不崩溃的错误不会触发 §4.5 回退),多加的后果是更早的宿主从此收不到更新。漏加只可能发生在宿主仓,由宿主构建拦:契约快照(宿主组件 schema、能力名、tinyui 版本)按 rv 各存一份入库,当前契约与当前 rv 的快照不符即构建失败;已随发版带出去的 rv,其快照冻结不可改。快照覆盖不到已有能力的参数与行为变化,这部分按上面的规则由人判断。为什么是精确匹配而不是 `>=` 范围,见 ADR-006 §2.2。 +漏加的后果是旧宿主收到跑不了的页面(不崩溃的错误不会触发 §4.5 回退),多加的后果是更早的宿主从此收不到更新。漏加只可能发生在宿主仓,由宿主构建拦:宿主快照 `tinyui-host/.txt`(宿主组件 schema、能力名、tinyui 版本)每个版本一份入库,当前宿主与当前版本的快照不符即构建失败;已随发版带出去的版本,其快照冻结不可改。快照覆盖不到已有能力的参数与行为变化,这部分按上面的规则由人判断。为什么是精确匹配而不是 `>=` 范围,见 ADR-006 §2.2。 **接入约定:`check()` 在任何 TinyUI 页面挂载之前调用**(App 启动即调)。指针回滚对已装上的用户生效靠的是下一次 `check()` 装上回退版本;页面若先于它崩溃,回滚永远送不到。进程级崩溃(native 段错误)不在 §4.5 的回退范围内,出口就是灰度 + 指针回滚 + 这条约定;挂载标记见 ADR-006 §4.3 推迟项。 @@ -175,9 +175,9 @@ class Updates( | | 自动止血 | 超阈值自动把 rollout 拨 0 或指针退回上一个好版本,按包开关与阈值 | 同上 + 发布协议 | 二期 | | | 失败原因分布 | `Failed` 按 stage、`Skipped` 按 reason 分布——发错目录、公钥没换、CDN 缓存坏各有各的形状 | `Failed.stage`、`Skipped.reason` | MVP | | 排障 | 回退关联的页面错误 | `RolledBack` 的 kind / page / buildId 聚合,可对回 source map | `RolledBack` | 二期 | -| | 兼容性错配 | 各不匹配项各多少台;App 新版发出后旧 rv 还剩多少活跃设备,决定何时停发旧 rv | `Skipped.mismatch` | 二期 | +| | 兼容性错配 | 各不匹配项各多少台;App 新版发出后旧 hostVersion 还剩多少活跃设备,决定何时停发旧 hostVersion | `Skipped.mismatch` | 二期 | | | 按租户标识查询 | 租户在上报里自带用户 / 设备标识时,查该标识最近跑什么版本、有没有回退 | 上报里的 opaque 字段,服务端只存不解释 | 按需 | -| 健康巡检 | runtimeVersion 存量 | 每个 rv 还有多少活跃设备,哪些 rv 可停止维护 | `Running` 按 rv | 按需 | +| 健康巡检 | hostVersion 存量 | 每个 hostVersion 还有多少活跃设备,哪些 hostVersion 可停止维护 | `Running` 按 hostVersion | 按需 | | | 内置包占比 | 长期跑内置包的比例,高了说明 `check()` 没被调用或更新服务被网络策略挡了 | `Running.source` | 按需 | | | 上报健康 | 遥测事件量与投递日志的比值 | 两者 | 按需 | @@ -186,19 +186,19 @@ class Updates( ### 4.3 `check(pkg)` 状态机 ``` -fetch //current.json ─解析失败──────────────────────────────▶ Failed(pointer) +fetch //current.json ─解析失败──────────────────────────────▶ Failed(pointer) │ ├─ version == installed.version 或 version ∈ failed ────────────────▶ UpToDate / Skipped(failed) ├─ hash(installId + ":" + pkg + ":" + version) % 100 ≥ rollout ─────▶ Skipped(rollout) │ - ▼ fetch ///manifest.json,拿到原始字节 + ▼ fetch ///manifest.json,拿到原始字节 ├─ 用内置 publicKey 对原始字节验 signature 失败 ─────────────────────▶ Failed(signature) // 上报 ├─ 解析失败 ────────────────────────────────────────────────────────▶ Failed(manifest) - ├─ name ≠ pkg / version ≠ 指针 version / runtimeVersion ≠ 宿主值 + ├─ name ≠ pkg / version ≠ 指针 version / hostVersion ≠ 宿主值 │ / engine ≠ QuickJs.upstreamCommit / protocol ≠ PROTOCOL ─────────▶ Skipped(incompatible) // 发错目录,上报 ├─ createdAt ≤ embedded.createdAt ───────────────────────────────────▶ Skipped(older-than-embedded) │ - ▼ 逐文件 fetch ///.bin → /staging//,每个核对 sha256 + ▼ 逐文件 fetch ///.bin → /staging//,每个核对 sha256 ├─ 任一失败 ─删 staging──────────────────────────────────────────────▶ Failed(download | integrity) ▼ manifest 原始字节写入 staging//manifest.json,staging/ 改名 installed/,state.installed = version,删其他 installed ▼ Installed(version) // 下次启动生效 @@ -218,7 +218,7 @@ manifest 一到手先验签再解析:签名不对的 manifest 里任何字段 `state.json` 写入走临时文件加 rename,任何时刻磁盘上都是一份完整的它。 -构造时对每个包:`installed` 存在、`name` 等于包名、`runtimeVersion` 等于宿主值、不在 `failed`、`createdAt` 新于 `embedded`、**逐文件重算 sha256 与 manifest 一致** → `current(pkg) = installed`,否则 `= embedded`。sha256 不一致的(磁盘损坏、半个文件)按失败处理:`failed += version`、`installed = null`、`Failed(integrity)`;校验时读进内存的字节直接作为该包 `Bundle` 的来源,进页面不再读磁盘。`createdAt` 不新于内置或 `runtimeVersion` 不等于宿主值的 installed 当场删除(App 升级带来了更新的内置包或 bump 了 rv);残留的 `staging/` 删除;`` 下不属于任何内置包的子目录删除(App 升级去掉了某个包)。不重验签:签名在落盘前验过,之后文件内容由 sha256 锁住;整包不到 100 KB,重算不到 1 毫秒。 +构造时对每个包:`installed` 存在、`name` 等于包名、`hostVersion` 等于宿主值、不在 `failed`、`createdAt` 新于 `embedded`、**逐文件重算 sha256 与 manifest 一致** → `current(pkg) = installed`,否则 `= embedded`。sha256 不一致的(磁盘损坏、半个文件)按失败处理:`failed += version`、`installed = null`、`Failed(integrity)`;校验时读进内存的字节直接作为该包 `Bundle` 的来源,进页面不再读磁盘。`createdAt` 不新于内置或 `hostVersion` 不等于宿主值的 installed 当场删除(App 升级带来了更新的内置包或 bump 了 hostVersion);残留的 `staging/` 删除;`` 下不属于任何内置包的子目录删除(App 升级去掉了某个包)。不重验签:签名在落盘前验过,之后文件内容由 sha256 锁住;整包不到 100 KB,重算不到 1 毫秒。 ### 4.5 失败回退 @@ -239,14 +239,14 @@ manifest 一到手先验签再解析:签名不对的 manifest 里任何字段 服务端实现的第二组端点,`tinyui-cli` 是它的客户端。所有请求 `Authorization: Bearer `;发布 token 按 (app, pkg) 签发并限定可写的 channel 集合,只对该 app 该包的路径有效;管理端点用实例的 `ADMIN_TOKEN`。 -服务端把内容按 (app, pkg, rv, version) 存一份,channel 只是指针:同一个 version 发到 staging 验证后,把 production 的指针指过去即是发布,不重传、不重签。内容 GET 路径里的 `` 段因此不参与寻址。 +服务端把内容按 (app, pkg, hostVersion, version) 存一份,channel 只是指针:同一个 version 发到 staging 验证后,把 production 的指针指过去即是发布,不重传、不重签。内容 GET 路径里的 `` 段因此不参与寻址。 ### 6.1 发布 | 请求 | 语义 | 服务端校验 | |---|---|---| -| `PUT /////`,body 为文件字节 | 上传内容,含 `manifest.json` | 幂等;同路径已有不同内容 → 409(version 不可变) | -| `PUT /////current.json`,body 为指针 | 写指针,即发布 | token 覆盖该 channel;`/manifest.json` 已上传,用该包登记的公钥对它的原始字节验 `signature`(§7);manifest 的 `name` == ``、`runtimeVersion` == ``、`version` == 指针 `version`、`publicKey` == 登记值;`files` 列出的每个文件已在 `/` 下且 sha256 与 `hashes` 一致;通过后记录 release(含 `signature`)、切指针 | +| `PUT /////`,body 为文件字节 | 上传内容,含 `manifest.json` | 幂等;同路径已有不同内容 → 409(version 不可变) | +| `PUT /////current.json`,body 为指针 | 写指针,即发布 | token 覆盖该 channel;`/manifest.json` 已上传,用该包登记的公钥对它的原始字节验 `signature`(§7);manifest 的 `name` == ``、`hostVersion` == ``、`version` == 指针 `version`、`publicKey` == 登记值;`files` 列出的每个文件已在 `/` 下且 sha256 与 `hashes` 一致;通过后记录 release(含 `signature`)、切指针 | 顺序由 CLI 保证:内容先、指针后。指针请求在内容不齐时拒绝,所以乱序不会产生半个包。 @@ -256,8 +256,8 @@ manifest 一到手先验签再解析:签名不对的 manifest 里任何字段 | 请求 | 语义 | |---|---| -| `GET ////releases` | 已发布的 version 列表:`createdAt`(包的构建时刻,来自 manifest)、`publishedAt`(服务端记下的发布时刻)、各 channel 的指针与 `rollout` | -| `POST /////pointer`,body `{ "version": …, "rollout"?: … }` | 指针指向 (app, pkg, rv) 下任一已发布 version——回滚与跨 channel 晋级是同一个操作;或只改当前指针的 `rollout`。服务端用记录里该 version 的 `signature` 重写 `current.json` | +| `GET ////releases` | 已发布的 version 列表:`createdAt`(包的构建时刻,来自 manifest)、`publishedAt`(服务端记下的发布时刻)、各 channel 的指针与 `rollout` | +| `POST /////pointer`,body `{ "version": …, "rollout"?: … }` | 指针指向 (app, pkg, hostVersion) 下任一已发布 version——回滚与跨 channel 晋级是同一个操作;或只改当前指针的 `rollout`。服务端用记录里该 version 的 `signature` 重写 `current.json` | 服务端按 version 保存 release 记录(`createdAt`、`signature`),指针切换不需要重新上传;改 `rollout` 只改指针,签名不受影响(§7)。CLI:`tinyui releases list` / `rollback ` / `rollout

` / `promote --to `。 @@ -276,7 +276,7 @@ CLI:`tinyui apps create` / `tinyui packages create` / `tinyui tokens create` / ## 7. 签名 - 算法 **ECDSA P-256 + SHA-256**,签名值 DER 编码后 base64 写入 `current.json` 的 `signature`(Java 与 iOS 原生都出 / 收 DER;WebCrypto 是 r‖s,服务端验签前转一次,约 20 行)。选它而非 ed25519:两端零依赖——Android `java.security.Signature("SHA256withECDSA")`,iOS `Security.framework` 的 `SecKeyVerifySignature`(C API,Kotlin/Native 可调;ed25519 在 iOS 只有 Swift-only 的 CryptoKit) -- 被签内容:`/manifest.json` 文件的**原始字节**。`bundle` 写定该文件后对字节签名,此后任何一端都不重新序列化,客户端与服务端拿到的字节就是被签的字节,不存在规范化这一步(DSSE、JWS、Expo code signing、APT 的通行做法;多端各实现一份规范化 JSON 是 TUF 早期路线,任一处偏差即全部客户端拒收全部更新且只能发 App 修)。签名值放在指针 `current.json` 里;指针与 `rollout` 不在签名内,服务端因此能改灰度比例、切回滚而不碰私钥,篡改指针最多改变谁拿到一个本就合法的包。`name` 与 `runtimeVersion` 在签名内,所以一个包的产物不可能被发成另一个包或另一个 rv +- 被签内容:`/manifest.json` 文件的**原始字节**。`bundle` 写定该文件后对字节签名,此后任何一端都不重新序列化,客户端与服务端拿到的字节就是被签的字节,不存在规范化这一步(DSSE、JWS、Expo code signing、APT 的通行做法;多端各实现一份规范化 JSON 是 TUF 早期路线,任一处偏差即全部客户端拒收全部更新且只能发 App 修)。签名值放在指针 `current.json` 里;指针与 `rollout` 不在签名内,服务端因此能改灰度比例、切回滚而不碰私钥,篡改指针最多改变谁拿到一个本就合法的包。`name` 与 `hostVersion` 在签名内,所以一个包的产物不可能被发成另一个包或另一个 hostVersion - 公钥格式统一为 **X9.63 未压缩点(`04‖X‖Y`,65 字节)的 base64**:iOS `SecKeyCreateWithData` 直接收,WebCrypto `importKey("raw")` 直接收,Android 侧加固定 26 字节的 P-256 SPKI DER 头再交 `X509EncodedKeySpec`——三处都不用解析 PEM。`tinyui.config.json` 的 `publicKey`、manifest 的 `publicKey`、`POST /apps//packages` 的 `publicKey`、`keys generate` 的公钥输出都是它 - 密钥按 (app, pkg) 一对:`tinyui keys generate` 产私钥 PEM(PKCS#8)与上述格式的公钥。私钥只在该包发布方的 CI(`tinyui bundle --signing-key`);公钥两处登记——包的 `tinyui.config.json`(随 build 进 manifest,随内置包进 App,是客户端的信任锚),服务端包记录(§6.3)。服务端永远接触不到私钥:token 被盗发不出客户端认的包,服务端被攻破发出的包客户端不认。一个团队持有的私钥只能签自己的包 - **下载的 manifest 里的 `publicKey` 永远不是信任来源**,客户端只拿它与内置的比对以给出诊断信息 diff --git a/packages/cli/src/admin.ts b/packages/cli/src/admin.ts index a03e884..b83f435 100644 --- a/packages/cli/src/admin.ts +++ b/packages/cli/src/admin.ts @@ -50,15 +50,15 @@ export function revokeToken(client: UpdatesClient, app: string, pkg: string, tok return client.empty("DELETE", segments("apps", app, "packages", pkg, "tokens", tokenId)); } -export function listReleases(client: UpdatesClient, app: string, pkg: string, runtimeVersion: string): Promise { - return client.json("GET", segments(app, pkg, runtimeVersion, "releases")); +export function listReleases(client: UpdatesClient, app: string, pkg: string, hostVersion: string): Promise { + return client.json("GET", segments(app, pkg, hostVersion, "releases")); } /** Rollback, promotion between channels and a rollout change are one endpoint (docs/updates.md §6.2). */ export function movePointer( client: UpdatesClient, - target: { app: string; channel: string; pkg: string; runtimeVersion: string }, + target: { app: string; channel: string; pkg: string; hostVersion: string }, body: { version?: string; rollout?: number }, ): Promise { - return client.json("POST", segments(target.app, target.channel, target.pkg, target.runtimeVersion, "pointer"), body); + return client.json("POST", segments(target.app, target.channel, target.pkg, target.hostVersion, "pointer"), body); } diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 5fd96f8..9d0ecf5 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -4,7 +4,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { createApp, createPackage, createToken, listReleases, movePointer, revokeToken, rotatePublicKey } from "./admin.ts"; import { build } from "./build.ts"; -import { bundle, isPathSegment } from "./bundle.ts"; +import { bundle, isHostVersion, isPathSegment } from "./bundle.ts"; import { ADMIN_TOKEN_ENV, DEFAULT_URL, resolveUrl, requireToken, TOKEN_ENV, UpdatesClient } from "./client.ts"; import { loadConfig, requireName } from "./config.ts"; import { generateKeyPair } from "./keys.ts"; @@ -13,7 +13,7 @@ import { generateKt, generateTs } from "./schema/generate.ts"; import { loadSchema } from "./schema/load.ts"; const USAGE = `usage: tinyui build [--root

] [--out ] [--qjsc ] [--js-only] [--version ] - tinyui bundle --runtime-version --signing-key [--dist ] [--rollout <0-100>] [--out ] + tinyui bundle --host-version --signing-key [--dist ] [--rollout <0-100>] [--out ] tinyui keys generate [--out ] tinyui schema --entry [--ts ] [--kt --package [--object ]] [--check] tinyui publish --channel [--app ] [--dir ] [--rollout <0-100>] @@ -22,10 +22,10 @@ const USAGE = `usage: tinyui build [--root ] [--out ] [--qjsc ] tinyui packages rotate-key --public-key [--app ] tinyui tokens create --channels [--app ] tinyui tokens revoke --id [--app ] - tinyui releases list --runtime-version [--pkg

] [--app ] [--json] - tinyui releases rollback --runtime-version --channel [--pkg

] [--app ] - tinyui releases rollout <0-100> --runtime-version --channel [--pkg

] [--app ] - tinyui releases promote --runtime-version --to [--pkg

] [--app ] + tinyui releases list --host-version [--pkg

] [--app ] [--json] + tinyui releases rollback --host-version --channel [--pkg

] [--app ] + tinyui releases rollout <0-100> --host-version --channel [--pkg

] [--app ] + tinyui releases promote --host-version --to [--pkg

] [--app ] build compile the pages of the package described by /tinyui.config.json --root project root (default: cwd) @@ -34,8 +34,8 @@ build compile the pages of the package described by /tinyui.config.js --js-only emit ESM sources and source maps only, skip bytecode --version package version to record (default: -) -bundle turn a build into the signed upload directory /// (docs/updates.md) - --runtime-version the host's declared runtimeVersion +bundle turn a build into the signed upload directory /// (docs/updates.md) + --host-version the positive integer the host declares for what it provides to pages (docs/updates.md §4.1) --signing-key PKCS#8 PEM whose public key is the one in tinyui.config.json --dist tinyui build output (default: ./dist) --rollout percentage written to current.json (default: 100) @@ -53,7 +53,7 @@ schema publish upload a bundle and move the channel pointer (docs/updates.md §6.1) --channel the channel to publish to; never defaulted, never read from the environment - --dir a tinyui bundle output root or one / inside it (default: ./dist/ota) + --dir a tinyui bundle output root or one / inside it (default: ./dist/ota) --rollout overrides the percentage current.json was bundled with the publishing and management commands talk to --url, default ${DEFAULT_URL} (or $TINYUI_UPDATES_URL) @@ -67,7 +67,7 @@ type Options = NonNullable; type Values = Record; const URL_OPTION: Options = { url: { type: "string" } }; -const RELEASE_OPTIONS: Options = { ...URL_OPTION, app: { type: "string" }, pkg: { type: "string" }, "runtime-version": { type: "string" }, channel: { type: "string" }, to: { type: "string" }, json: { type: "boolean", default: false } }; +const RELEASE_OPTIONS: Options = { ...URL_OPTION, app: { type: "string" }, pkg: { type: "string" }, "host-version": { type: "string" }, channel: { type: "string" }, to: { type: "string" }, json: { type: "boolean", default: false } }; const COMMANDS: Record Promise }> = { build: { @@ -85,15 +85,15 @@ const COMMANDS: Record { - const runtimeVersion = v["runtime-version"] as string | undefined; + const hostVersion = v["host-version"] as string | undefined; const signingKey = v["signing-key"] as string | undefined; - if (!runtimeVersion) throw new Error("bundle: --runtime-version is required"); + if (!hostVersion) throw new Error("bundle: --host-version is required"); if (!signingKey) throw new Error("bundle: --signing-key is required"); const result = await bundle({ dist: (v["dist"] as string | undefined) ?? resolve("dist"), - runtimeVersion, + hostVersion, signingKey, ...(v["rollout"] !== undefined && { rollout: percentage(v["rollout"] as string, "bundle: --rollout") }), ...(v["out"] !== undefined && { out: v["out"] as string }), @@ -141,8 +141,8 @@ const COMMANDS: Record process.stderr.write(`${u.existing ? "exists " : "uploaded"} ${u.path}\n`), }); - const { pkg, runtimeVersion, version, pointer } = result; - process.stderr.write(`published ${pkg}/${runtimeVersion}/${version} to ${v["channel"] as string} at rollout ${pointer.rollout} (${result.uploaded} uploaded, ${result.existing} already there)\n`); + const { pkg, hostVersion, version, pointer } = result; + process.stderr.write(`published ${pkg}/${hostVersion}/${version} to ${v["channel"] as string} at rollout ${pointer.rollout} (${result.uploaded} uploaded, ${result.existing} already there)\n`); process.stdout.write(`${version}\n`); return 0; }, @@ -214,9 +214,10 @@ async function releases(v: Values, positionals: string[]): Promise { const client = publishClient(v); const app = await appId(v); const pkg = await packageName(v, undefined); - const runtimeVersion = pathSegment(required(v["runtime-version"], `releases ${action ?? ""}: --runtime-version is required`), "--runtime-version"); + const hostVersion = required(v["host-version"], `releases ${action ?? ""}: --host-version is required`); + if (!isHostVersion(hostVersion)) throw new Error(`--host-version must be a positive integer, got "${hostVersion}"`); if (action === "list") { - const found = await listReleases(client, app, pkg, runtimeVersion); + const found = await listReleases(client, app, pkg, hostVersion); if (v["json"] as boolean) { process.stdout.write(`${JSON.stringify(found, null, 2)}\n`); return 0; @@ -232,7 +233,7 @@ async function releases(v: Values, positionals: string[]): Promise { for (const r of found.versions) process.stdout.write(`${r.version.padEnd(width)} ${(r.publishedAt ?? r.createdAt ?? "").padEnd(24)} ${at(r.version)}\n`); return 0; } - const target = { app, pkg, runtimeVersion, channel: "" }; + const target = { app, pkg, hostVersion, channel: "" }; let body: { version?: string; rollout?: number }; if (action === "rollback" || action === "promote") { const version = pathSegment(required(positionals[2], `releases ${action}: a version is required`), "a version"); @@ -245,7 +246,7 @@ async function releases(v: Values, positionals: string[]): Promise { throw new Error("releases: expected list, rollback, rollout or promote"); } const pointer = await movePointer(client, target, body); - process.stderr.write(`${app}/${pkg}/${runtimeVersion} ${target.channel} -> ${pointer.version} at rollout ${pointer.rollout}\n`); + process.stderr.write(`${app}/${pkg}/${hostVersion} ${target.channel} -> ${pointer.version} at rollout ${pointer.rollout}\n`); process.stdout.write(`${pointer.version}\n`); return 0; } diff --git a/packages/cli/src/bundle.ts b/packages/cli/src/bundle.ts index 279bf15..2c06dc2 100644 --- a/packages/cli/src/bundle.ts +++ b/packages/cli/src/bundle.ts @@ -8,8 +8,8 @@ import { isPublicKey, publicKeyOf, sign } from "./keys.ts"; export interface BundleOptions { /** `tinyui build` output directory. */ dist: string; - /** The host's declared runtimeVersion; becomes a path segment and a signed manifest field. */ - runtimeVersion: string; + /** What the host declares it provides to pages (docs/updates.md §4.1); becomes a path segment and a signed manifest field. */ + hostVersion: string; /** PKCS#8 PEM whose public key must equal the manifest's `publicKey`. */ signingKey: string; /** 0–100, defaults to 100. */ @@ -19,7 +19,7 @@ export interface BundleOptions { } export interface BundleResult { - /** `//`: upload it with `tinyui publish` or serve it as is (docs/updates.md §1.2). */ + /** `//`: upload it with `tinyui publish` or serve it as is (docs/updates.md §1.2). */ dir: string; version: string; pointer: string; @@ -34,17 +34,22 @@ export interface Pointer { signature: string; } -/** `version` and `runtimeVersion` become directory names; one segment, never `.` or `..`. */ +/** `version` becomes a directory name; one segment, never `.` or `..`. */ export function isPathSegment(value: string): boolean { return /^[A-Za-z0-9._-]+$/.test(value) && value !== "." && value !== ".."; } +/** A positive integer, so an App version such as `1.2.0` cannot be mistaken for a host version. */ +export function isHostVersion(value: string): boolean { + return /^[1-9][0-9]*$/.test(value); +} + export async function bundle(options: BundleOptions): Promise { const dist = resolve(options.dist); const out = resolve(options.out ?? join(dist, "ota")); const rollout = options.rollout ?? 100; if (!Number.isInteger(rollout) || rollout < 0 || rollout > 100) throw new Error(`rollout must be an integer from 0 to 100, got ${options.rollout}`); - if (!isPathSegment(options.runtimeVersion)) throw new Error(`runtime version must be a single path segment, got "${options.runtimeVersion}"`); + if (!isHostVersion(options.hostVersion)) throw new Error(`host version must be a positive integer, got "${options.hostVersion}"; it counts what the host provides to pages, it is not the App version`); const manifest = await readManifest(dist); if (!isPathSegment(manifest.version)) throw new Error(`manifest version must be a single path segment, got "${manifest.version}"`); @@ -70,8 +75,8 @@ export async function bundle(options: BundleOptions): Promise { } // written once, signed as written: every side verifies these exact bytes (docs/updates.md §7) - const signed = Buffer.from(JSON.stringify({ ...manifest, runtimeVersion: options.runtimeVersion }, null, 2) + "\n"); - const dir = join(out, manifest.name, options.runtimeVersion); + const signed = Buffer.from(JSON.stringify({ ...manifest, hostVersion: options.hostVersion }, null, 2) + "\n"); + const dir = join(out, manifest.name, options.hostVersion); const versionDir = join(dir, manifest.version); const manifestFile = join(versionDir, "manifest.json"); const existing = await readFile(manifestFile).catch(() => null); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 91c321f..f6fd416 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,7 +2,7 @@ export { build, RUNTIME_MODULES } from "./build.ts"; export type { BuildOptions, BuildResult, BuiltModule, Manifest } from "./build.ts"; export { createApp, createPackage, createToken, listReleases, movePointer, revokeToken, rotatePublicKey } from "./admin.ts"; export type { AppRecord, IssuedToken, PackageRecord, Releases } from "./admin.ts"; -export { bundle, isPathSegment } from "./bundle.ts"; +export { bundle, isHostVersion, isPathSegment } from "./bundle.ts"; export type { BundleOptions, BundleResult, Pointer } from "./bundle.ts"; export { ADMIN_TOKEN_ENV, DEFAULT_URL, requireSecureUrl, requireToken, resolveUrl, segments, TOKEN_ENV, UpdatesClient, UpdatesError, URL_ENV } from "./client.ts"; export type { ClientOptions, UploadResult } from "./client.ts"; diff --git a/packages/cli/src/publish.ts b/packages/cli/src/publish.ts index 5f9abd7..f05131f 100644 --- a/packages/cli/src/publish.ts +++ b/packages/cli/src/publish.ts @@ -1,12 +1,12 @@ import { readFile, readdir, realpath } from "node:fs/promises"; import { isAbsolute, join, relative, resolve, sep } from "node:path"; import type { Manifest } from "./build.ts"; -import { isPathSegment, type Pointer } from "./bundle.ts"; +import { isHostVersion, isPathSegment, type Pointer } from "./bundle.ts"; import { segments, type UpdatesClient, type UploadResult } from "./client.ts"; export interface PublishOptions { client: UpdatesClient; - /** `tinyui bundle` output: either `` or the `//` inside it. */ + /** `tinyui bundle` output: either `` or the `//` inside it. */ dir: string; app: string; channel: string; @@ -18,20 +18,20 @@ export interface PublishOptions { export interface PublishResult { pkg: string; - runtimeVersion: string; + hostVersion: string; version: string; uploaded: number; existing: number; pointer: Pointer; } -/** The signed bundle of one (pkg, rv), as `tinyui bundle` laid it out (docs/updates.md §1.2). */ +/** The signed bundle of one (pkg, hostVersion), as `tinyui bundle` laid it out (docs/updates.md §1.2). */ interface LocalBundle { dir: string; pointer: Pointer; /** The bytes that were signed; they are uploaded unchanged (docs/updates.md §7). */ manifestBytes: Uint8Array; - manifest: Manifest & { runtimeVersion: string }; + manifest: Manifest & { hostVersion: string }; } export async function publish(options: PublishOptions): Promise { @@ -44,9 +44,9 @@ export async function publish(options: PublishOptions): Promise { const local = await load(await discover(resolve(options.dir))); const { manifest, pointer } = local; const pkg = manifest.name; - const rv = manifest.runtimeVersion; + const hostVersion = manifest.hostVersion; const version = manifest.version; - const prefix = segments(app, pkg, rv, version); + const prefix = segments(app, pkg, hostVersion, version); const files = [...new Set(Object.values(manifest.files).map((path) => `${path}.bin`)), "manifest.json"]; const results: UploadResult[] = []; @@ -59,10 +59,10 @@ export async function publish(options: PublishOptions): Promise { }); const body = { version, signature: pointer.signature, rollout: options.rollout ?? pointer.rollout }; - const written = await client.json("PUT", `${segments(app, channel, pkg, rv)}/current.json`, body); + const written = await client.json("PUT", `${segments(app, channel, pkg, hostVersion)}/current.json`, body); return { pkg, - runtimeVersion: rv, + hostVersion, version, uploaded: results.filter((r) => !r.existing).length, existing: results.filter((r) => r.existing).length, @@ -70,13 +70,13 @@ export async function publish(options: PublishOptions): Promise { }; } -/** `

` is either the `/` directory itself or the root `tinyui bundle --out` wrote it under. */ +/** `` is either the `/` directory itself or the root `tinyui bundle --out` wrote it under. */ async function discover(dir: string): Promise { if (await exists(join(dir, "current.json"))) return dir; const found: string[] = []; for (const pkg of await subdirectories(dir)) { - for (const rv of await subdirectories(join(dir, pkg))) { - if (await exists(join(dir, pkg, rv, "current.json"))) found.push(join(dir, pkg, rv)); + for (const hostVersion of await subdirectories(join(dir, pkg))) { + if (await exists(join(dir, pkg, hostVersion, "current.json"))) found.push(join(dir, pkg, hostVersion)); } } if (found.length === 1) return found[0]!; @@ -98,10 +98,11 @@ async function load(dir: string): Promise { const manifestBytes = await readInside(join(dir, pointer.version), "manifest.json").catch((e: unknown) => { throw new Error(`cannot read ${manifestFile} (${(e as Error).message}); current.json points at a version that was not bundled here`); }); - const manifest = JSON.parse(Buffer.from(manifestBytes).toString("utf8")) as Manifest & { runtimeVersion?: string }; - for (const key of ["name", "version", "runtimeVersion"] as const) { + const manifest = JSON.parse(Buffer.from(manifestBytes).toString("utf8")) as Manifest & { hostVersion?: string }; + for (const key of ["name", "version", "hostVersion"] as const) { if (typeof manifest[key] !== "string" || manifest[key] === "") throw new Error(`${manifestFile} has no "${key}"; rebuild with a current tinyui-cli`); } + if (!isHostVersion(manifest.hostVersion!)) throw new Error(`${manifestFile}: hostVersion must be a positive integer, got "${manifest.hostVersion}"`); // the same three checks the server runs (§6.1), so a mismatch costs no upload if (manifest.version !== pointer.version) throw new Error(`${manifestFile} is version ${manifest.version}, current.json points at ${pointer.version}`); // these paths pick which files are read and uploaded, so they stay inside / and inside the URL charset @@ -111,7 +112,7 @@ async function load(dir: string): Promise { throw new Error(`${manifestFile}: files["${module}"] = ${JSON.stringify(path)}; every segment must match [A-Za-z0-9._-]+`); } } - return { dir, pointer: pointer as Pointer, manifestBytes, manifest: manifest as Manifest & { runtimeVersion: string } }; + return { dir, pointer: pointer as Pointer, manifestBytes, manifest: manifest as Manifest & { hostVersion: string } }; } /** Lexical checks do not stop a symlink: a bundle from elsewhere could point at any file on the machine. */ diff --git a/packages/cli/test/admin.test.ts b/packages/cli/test/admin.test.ts index 61c5ecb..144cb33 100644 --- a/packages/cli/test/admin.test.ts +++ b/packages/cli/test/admin.test.ts @@ -55,7 +55,7 @@ describe("management commands", () => { assert.equal(releases.versions.length, 2); assert.equal(releases.channels["staging"]?.rollout, 20); - const target = { app: "demo", pkg: "fixture", runtimeVersion: "1" }; + const target = { app: "demo", pkg: "fixture", hostVersion: "1" }; const rolledBack = await movePointer(client, { ...target, channel: "production" }, { version: "20260921T080000Z-aaaaaa" }); assert.equal(last().path, "/demo/production/fixture/1/pointer"); assert.equal(rolledBack.version, "20260921T080000Z-aaaaaa"); diff --git a/packages/cli/test/bundle.test.ts b/packages/cli/test/bundle.test.ts index 9f1d37d..0234971 100644 --- a/packages/cli/test/bundle.test.ts +++ b/packages/cli/test/bundle.test.ts @@ -30,7 +30,7 @@ describe("tinyui bundle", () => { it("writes current.json next to an immutable / holding manifest.json and the bytecode", async () => { const dist = await fakeDist(join(tmp, "dist")); - const result = await bundle({ dist, runtimeVersion: "1", signingKey, rollout: 30 }); + const result = await bundle({ dist, hostVersion: "1", signingKey, rollout: 30 }); assert.equal(result.dir, join(dist, "ota", "fixture", "1")); assert.equal(result.version, "20260922T090000Z-3f2a1c"); const versionDir = join(result.dir, result.version); @@ -45,15 +45,15 @@ describe("tinyui bundle", () => { assert.deepEqual(Object.keys(pointer), ["version", "rollout", "signature"]); assert.equal(pointer.version, result.version); assert.equal(pointer.rollout, 30); - const manifest = JSON.parse(await readFile(result.manifest, "utf8")) as Manifest & { runtimeVersion: string }; - assert.equal(manifest.runtimeVersion, "1"); + const manifest = JSON.parse(await readFile(result.manifest, "utf8")) as Manifest & { hostVersion: string }; + assert.equal(manifest.hostVersion, "1"); assert.equal(manifest.name, "fixture"); assert.ok(!("signature" in manifest) && !("rollout" in manifest), "nothing mutable in the signed file"); }); it("signs the raw bytes of /manifest.json, so the public key verifies the file as stored", async () => { const dist = await fakeDist(join(tmp, "dist-raw")); - const result = await bundle({ dist, runtimeVersion: "1", signingKey }); + const result = await bundle({ dist, hostVersion: "1", signingKey }); const pointer = JSON.parse(await readFile(result.pointer, "utf8")) as Pointer; const bytes = await readFile(result.manifest); assert.ok(verify(config.publicKey, bytes, pointer.signature)); @@ -65,87 +65,89 @@ describe("tinyui bundle", () => { it("defaults rollout to 100 and lets it change without touching the signature", async () => { const dist = await fakeDist(join(tmp, "dist-rollout")); - const result = await bundle({ dist, runtimeVersion: "1", signingKey }); + const result = await bundle({ dist, hostVersion: "1", signingKey }); const pointer = JSON.parse(await readFile(result.pointer, "utf8")) as Pointer; assert.equal(pointer.rollout, 100); pointer.rollout = 5; await writeFile(result.pointer, JSON.stringify(pointer)); const edited = JSON.parse(await readFile(result.pointer, "utf8")) as Pointer; assert.ok(verify(config.publicKey, await readFile(result.manifest), edited.signature)); - await assert.rejects(bundle({ dist, runtimeVersion: "1", signingKey, rollout: 101 }), /rollout must be an integer from 0 to 100/); + await assert.rejects(bundle({ dist, hostVersion: "1", signingKey, rollout: 101 }), /rollout must be an integer from 0 to 100/); }); it("refuses a signing key that does not match the package's publicKey, or a publicKey that is not P-256", async () => { const dist = await fakeDist(join(tmp, "dist-key")); const other = join(tmp, "other.pem"); await writeFile(other, generateKeyPair().privateKeyPem); - await assert.rejects(bundle({ dist, runtimeVersion: "1", signingKey: other }), /does not match the publicKey/); + await assert.rejects(bundle({ dist, hostVersion: "1", signingKey: other }), /does not match the publicKey/); const p384 = generateKeyPairSync("ec", { namedCurve: "P-384" }); const { x, y } = p384.publicKey.export({ format: "jwk" }) as { x: string; y: string }; const point = Buffer.concat([Buffer.from([4]), Buffer.from(x, "base64url"), Buffer.from(y, "base64url")]).toString("base64"); const wrongCurve = await fakeDist(join(tmp, "dist-p384"), (m) => { m.publicKey = point; }); const p384Key = join(tmp, "p384.pem"); await writeFile(p384Key, p384.privateKey.export({ type: "pkcs8", format: "pem" })); - await assert.rejects(bundle({ dist: wrongCurve, runtimeVersion: "1", signingKey: p384Key }), /not a P-256/); + await assert.rejects(bundle({ dist: wrongCurve, hostVersion: "1", signingKey: p384Key }), /not a P-256/); }); it("refuses a build whose bytecode changed since manifest.json was written, touching nothing", async () => { const dist = await fakeDist(join(tmp, "dist-stale")); await writeFile(join(dist, "pages", "home.bin"), "tampered"); - await assert.rejects(bundle({ dist, runtimeVersion: "1", signingKey }), /does not match manifest.hashes/); + await assert.rejects(bundle({ dist, hostVersion: "1", signingKey }), /does not match manifest.hashes/); await assert.rejects(readdir(join(dist, "ota")), /ENOENT/, "nothing was written"); }); it("keeps a version immutable: the same build again is fine, a different one is refused", async () => { const dist = await fakeDist(join(tmp, "dist-immutable")); - const first = await bundle({ dist, runtimeVersion: "1", signingKey, rollout: 10 }); + const first = await bundle({ dist, hostVersion: "1", signingKey, rollout: 10 }); const before = await stat(first.manifest); - const again = await bundle({ dist, runtimeVersion: "1", signingKey, rollout: 90 }); + const again = await bundle({ dist, hostVersion: "1", signingKey, rollout: 90 }); assert.equal((JSON.parse(await readFile(again.pointer, "utf8")) as Pointer).rollout, 90); assert.ok((await readFile(first.manifest)).equals(await readFile(again.manifest))); assert.equal((await stat(again.manifest)).mtimeMs, before.mtimeMs, "an identical version is left untouched"); assert.deepEqual((await readdir(first.dir)).filter((f) => f.endsWith(".tmp")), [], "no staging left behind"); const rebuilt = await fakeDist(join(tmp, "dist-immutable-2"), (m) => { m.createdAt = "2026-09-22T10:00:00Z"; }); - await assert.rejects(bundle({ dist: rebuilt, runtimeVersion: "1", signingKey, out: join(dist, "ota") }), /already holds a different build/); + await assert.rejects(bundle({ dist: rebuilt, hostVersion: "1", signingKey, out: join(dist, "ota") }), /already holds a different build/); // a directory without a manifest is never cleaned up on the caller's behalf const partial = await fakeDist(join(tmp, "dist-partial")); await mkdir(join(partial, "ota", "fixture", "1", "20260922T090000Z-3f2a1c", "runtime"), { recursive: true }); - await assert.rejects(bundle({ dist: partial, runtimeVersion: "1", signingKey }), /exists without a manifest.json/); + await assert.rejects(bundle({ dist: partial, hostVersion: "1", signingKey }), /exists without a manifest.json/); assert.deepEqual((await readdir(join(partial, "ota", "fixture", "1"))).filter((f) => f.endsWith(".tmp")), []); }); - it("never lets a version, runtime version or file path leave its directory", async () => { + it("never lets a version, host version or file path leave its directory", async () => { const escaping = await fakeDist(join(tmp, "dist-escape"), (m) => { m.version = "../../escape"; }); - await assert.rejects(bundle({ dist: escaping, runtimeVersion: "1", signingKey }), /manifest version must be a single path segment/); + await assert.rejects(bundle({ dist: escaping, hostVersion: "1", signingKey }), /manifest version must be a single path segment/); const dot = await fakeDist(join(tmp, "dist-dot"), (m) => { m.version = ".."; }); - await assert.rejects(bundle({ dist: dot, runtimeVersion: "1", signingKey }), /manifest version must be a single path segment/); - const dist = await fakeDist(join(tmp, "dist-rv-escape")); - await assert.rejects(bundle({ dist, runtimeVersion: "..", signingKey }), /runtime version must be a single path segment/); + await assert.rejects(bundle({ dist: dot, hostVersion: "1", signingKey }), /manifest version must be a single path segment/); + const dist = await fakeDist(join(tmp, "dist-hostVersion-escape")); + await assert.rejects(bundle({ dist, hostVersion: "..", signingKey }), /host version must be a positive integer/); const name = await fakeDist(join(tmp, "dist-name"), (m) => { m.name = "../pkg"; }); - await assert.rejects(bundle({ dist: name, runtimeVersion: "1", signingKey }), /manifest name must match/); + await assert.rejects(bundle({ dist: name, hostVersion: "1", signingKey }), /manifest name must match/); const files = await fakeDist(join(tmp, "dist-files"), (m) => { m.files["tinyui-core"] = "../outside/core"; }); await mkdir(join(tmp, "outside"), { recursive: true }); await writeFile(join(tmp, "outside", "core.bin"), "x"); - await assert.rejects(bundle({ dist: files, runtimeVersion: "1", signingKey }), /points outside/); + await assert.rejects(bundle({ dist: files, hostVersion: "1", signingKey }), /points outside/); await assert.rejects(readdir(join(tmp, "escape")), /ENOENT/); }); - it("refuses a --js-only build, a manifest from an older CLI and a bad runtime version", async () => { + it("refuses a --js-only build, a manifest from an older CLI and a bad host version", async () => { const jsOnly = await fakeDist(join(tmp, "dist-jsonly"), (m) => { m.engine = ""; }); - await assert.rejects(bundle({ dist: jsOnly, runtimeVersion: "1", signingKey }), /has no bytecode/); + await assert.rejects(bundle({ dist: jsOnly, hostVersion: "1", signingKey }), /has no bytecode/); const old = await fakeDist(join(tmp, "dist-old"), (m) => { delete (m as Partial).publicKey; }); - await assert.rejects(bundle({ dist: old, runtimeVersion: "1", signingKey }), /has no "publicKey"/); + await assert.rejects(bundle({ dist: old, hostVersion: "1", signingKey }), /has no "publicKey"/); const missing = await fakeDist(join(tmp, "dist-missing"), (m) => { delete m.files["tinyui-native"]; }); - await assert.rejects(bundle({ dist: missing, runtimeVersion: "1", signingKey }), /"files" does not cover exactly/); + await assert.rejects(bundle({ dist: missing, hostVersion: "1", signingKey }), /"files" does not cover exactly/); const extra = await fakeDist(join(tmp, "dist-extra"), (m) => { m.hashes["ghost"] = "00"; }); - await assert.rejects(bundle({ dist: extra, runtimeVersion: "1", signingKey }), /"hashes" does not cover exactly/); - const dist = await fakeDist(join(tmp, "dist-rv")); - await assert.rejects(bundle({ dist, runtimeVersion: "1/2", signingKey }), /runtime version must be a single path segment/); + await assert.rejects(bundle({ dist: extra, hostVersion: "1", signingKey }), /"hashes" does not cover exactly/); + const dist = await fakeDist(join(tmp, "dist-hostVersion")); + await assert.rejects(bundle({ dist, hostVersion: "1/2", signingKey }), /host version must be a positive integer/); + // an App version is the likeliest wrong value, and the one this rule exists for + for (const appVersion of ["1.2.0", "1.2", "01", "0"]) await assert.rejects(bundle({ dist, hostVersion: appVersion, signingKey }), /it is not the App version/); }); it("refuses an output path that would not survive the delivery URL", async () => { const chinese = await fakeDist(join(tmp, "dist-nonascii"), (m) => { m.files["fixture/home"] = "pages/订单"; }); - await assert.rejects(bundle({ dist: chinese, runtimeVersion: "1", signingKey }), /every segment must match \[A-Za-z0-9\._-\]\+ to survive the delivery URL/); + await assert.rejects(bundle({ dist: chinese, hostVersion: "1", signingKey }), /every segment must match \[A-Za-z0-9\._-\]\+ to survive the delivery URL/); }); it("bundles a real build end to end", { skip: !qjsc && "qjsc-kmp not found" }, async () => { @@ -156,7 +158,7 @@ describe("tinyui bundle", () => { await symlink(join(import.meta.dirname, "..", "node_modules"), join(root, "node_modules")); const dist = join(tmp, "real"); const built = await build({ root, out: dist }); - const result = await bundle({ dist, runtimeVersion: "1", signingKey }); + const result = await bundle({ dist, hostVersion: "1", signingKey }); const pointer = JSON.parse(await readFile(result.pointer, "utf8")) as Pointer; const bytes = await readFile(result.manifest); assert.ok(verify(config.publicKey, bytes, pointer.signature)); diff --git a/packages/cli/test/helpers.ts b/packages/cli/test/helpers.ts index c034f7e..d3d92c6 100644 --- a/packages/cli/test/helpers.ts +++ b/packages/cli/test/helpers.ts @@ -108,10 +108,10 @@ function defaultReply(method: string, path: string, body: Buffer, uploads: Map/packages/

/publicKey` diff --git a/packages/cli/test/publish.test.ts b/packages/cli/test/publish.test.ts index 38a4be1..5da9cc9 100644 --- a/packages/cli/test/publish.test.ts +++ b/packages/cli/test/publish.test.ts @@ -34,7 +34,7 @@ describe("tinyui publish", () => { /** One signed bundle under `/