diff --git a/README.md b/README.md index ee84471..686693c 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,12 @@ Two kinds of `GET`, relative to `https:////`: | Path | Content | Cache | |---|---|---| -| `//current.json` | mutable pointer: `{ version, rollout, signature }` | `no-store` | -| `///…` | immutable content: `manifest.json`, `runtime/*.bin`, `pages/**/*.bin` | `immutable` | +| `//current.json` | mutable pointer: `{ version, rollout, signature }` | `no-store` | +| `///…` | immutable content: `manifest.json`, `runtime/*.bin`, `pages/**/*.bin` | `immutable` | -Content is stored once per `(app, pkg, rv, version)`; a channel is only a pointer, so promoting a verified version from `staging` to `production` moves the pointer and transfers nothing. +`` is the positive integer a host App declares for what it provides to pages: it goes up when host components or capabilities change or the embedded TinyUI is upgraded. It is not the version of the TinyUI runtime or the JS engine (the manifest's `engine` / `protocol` cover those); it plays the role of Expo's `runtimeVersion` but is always a positive integer that counts host changes, never an App version such as `1.2.0`; see the TinyUI repo's `docs/updates.md`. + +Content is stored once per `(app, pkg, hostVersion, version)`; a channel is only a pointer, so promoting a verified version from `staging` to `production` moves the pointer and transfers nothing. ## Publishing @@ -23,10 +25,10 @@ All requests carry `Authorization: Bearer `. | Request | Meaning | |---|---| -| `PUT /////` | upload one file (idempotent; different bytes at an existing path → 409) | -| `PUT /////current.json` with `{ version, signature, rollout? }` | publish: verifies the signature over the uploaded `manifest.json` with the registered key, checks `name` / `runtimeVersion` / `version` / `publicKey` and every file's sha256, records the release, moves the pointer | -| `GET ////releases` | published versions and where each channel points | -| `POST /////pointer` with `{ version?, rollout? }` | rollback, promotion and rollout changes are the same operation | +| `PUT /////` | upload one file (idempotent; different bytes at an existing path → 409) | +| `PUT /////current.json` with `{ version, signature, rollout? }` | publish: verifies the signature over the uploaded `manifest.json` with the registered key, checks `name` / `hostVersion` / `version` / `publicKey` and every file's sha256, records the release, moves the pointer | +| `GET ////releases` | published versions and where each channel points | +| `POST /////pointer` with `{ version?, rollout? }` | rollback, promotion and rollout changes are the same operation | `tinyui publish` / `tinyui releases …` in `tinyui-cli` are thin clients of these endpoints. diff --git a/README.zh-CN.md b/README.zh-CN.md index bef9eff..aac4cee 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -12,10 +12,12 @@ | 路径 | 内容 | 缓存 | |---|---|---| -| `//current.json` | 可变指针:`{ version, rollout, signature }` | `no-store` | -| `///…` | 不可变内容:`manifest.json`、`runtime/*.bin`、`pages/**/*.bin` | `immutable` | +| `//current.json` | 可变指针:`{ version, rollout, signature }` | `no-store` | +| `///…` | 不可变内容:`manifest.json`、`runtime/*.bin`、`pages/**/*.bin` | `immutable` | -内容按 `(app, pkg, rv, version)` 存一份;channel 只是指针,staging 验过的版本晋级到 production 只动指针,不重传。 +`` 是宿主 App 为"它给页面提供了什么"声明的正整数:宿主组件、宿主能力增删或改动,或者升级了内置的 TinyUI,都加 1。它不是 TinyUI 运行时或 JS 引擎的版本(那两者由 manifest 的 `engine` / `protocol` 校验),作用同 Expo 的 `runtimeVersion`,但取值只能是正整数、数的是宿主的变化,不是 `1.2.0` 这类 App 版本号,见 TinyUI 仓的 `docs/updates.md`。 + +内容按 `(app, pkg, hostVersion, version)` 存一份;channel 只是指针,staging 验过的版本晋级到 production 只动指针,不重传。 ## 发布 @@ -23,10 +25,10 @@ | 请求 | 语义 | |---|---| -| `PUT /////` | 上传一个文件(幂等;同路径不同内容 → 409) | -| `PUT /////current.json`,body `{ version, signature, rollout? }` | 发布:用登记的公钥对已上传的 `manifest.json` 原始字节验签,核对 `name` / `runtimeVersion` / `version` / `publicKey` 与每个文件的 sha256,记录 release,切指针 | -| `GET ////releases` | 已发布的版本与各 channel 的指针 | -| `POST /////pointer`,body `{ version?, rollout? }` | 回滚、晋级、改灰度是同一个操作 | +| `PUT /////` | 上传一个文件(幂等;同路径不同内容 → 409) | +| `PUT /////current.json`,body `{ version, signature, rollout? }` | 发布:用登记的公钥对已上传的 `manifest.json` 原始字节验签,核对 `name` / `hostVersion` / `version` / `publicKey` 与每个文件的 sha256,记录 release,切指针 | +| `GET ////releases` | 已发布的版本与各 channel 的指针 | +| `POST /////pointer`,body `{ version?, rollout? }` | 回滚、晋级、改灰度是同一个操作 | `tinyui-cli` 的 `tinyui publish` / `tinyui releases …` 是这些端点的薄封装。 diff --git a/src/app.ts b/src/app.ts index c53928b..ad0642f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import type { Context } from "hono"; import { docKeys, isAdmin, issueToken, packageToken, revokeToken } from "./auth.ts"; -import { isName, isObjectPath, isSegment } from "./ids.ts"; +import { isHostVersion, isName, isObjectPath, isSegment } from "./ids.ts"; import { parseManifest } from "./manifest.ts"; import type { AppRecord, PackageRecord, PointerDoc, ReleaseRecord } from "./records.ts"; import { isPublicKey, verifySignature } from "./signature.ts"; @@ -18,7 +18,7 @@ const IMMUTABLE = "public, max-age=31536000, immutable"; export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono { const app = new Hono(); const fail = (c: Context, status: 400 | 401 | 403 | 404 | 409 | 413, error: string) => c.json({ error }, status); - const objectKey = (app: string, pkg: string, rv: string, version: string, path: string) => `${app}/${pkg}/${rv}/${version}/${path}`; + const objectKey = (app: string, pkg: string, hostVersion: string, version: string, path: string) => `${app}/${pkg}/${hostVersion}/${version}/${path}`; app.get("/", (c) => c.text("tinyui-updates")); @@ -86,18 +86,18 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono { // ---- releases (package token or admin), §6.2 ---- - app.get("/:app/:pkg/:rv/releases", async (c) => { - const { app: appId, pkg, rv } = c.req.param(); - if (!isName(appId) || !isName(pkg) || !isSegment(rv)) return fail(c, 404, "not found"); + app.get("/:app/:pkg/:hostVersion/releases", async (c) => { + const { app: appId, pkg, hostVersion } = c.req.param(); + if (!isName(appId) || !isName(pkg) || !isHostVersion(hostVersion)) return fail(c, 404, "not found"); if (!isAdmin(c, adminToken) && !(await packageToken(c, storage, appId, pkg))) return fail(c, 401, "a token for this package is required"); - const releasePrefix = docKeys.releasePrefix(appId, pkg, rv); + const releasePrefix = docKeys.releasePrefix(appId, pkg, hostVersion); const versions: { version: string; createdAt: string; publishedAt: string }[] = []; for (const key of await storage.listDocs(releasePrefix)) { const release = await storage.getDoc(key); if (release) versions.push({ version: key.slice(releasePrefix.length), createdAt: release.createdAt, publishedAt: release.publishedAt }); } versions.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)); - const pointerPrefix = docKeys.pointerPrefix(appId, pkg, rv); + const pointerPrefix = docKeys.pointerPrefix(appId, pkg, hostVersion); const channels: Record = {}; for (const key of await storage.listDocs(pointerPrefix)) { const pointer = await storage.getDoc(key); @@ -106,30 +106,30 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono { return c.json({ versions, channels }); }); - app.post("/:app/:channel/:pkg/:rv/pointer", async (c) => { - const { app: appId, channel, pkg, rv } = c.req.param(); - if (!isName(appId) || !isName(channel) || !isName(pkg) || !isSegment(rv)) return fail(c, 404, "not found"); + app.post("/:app/:channel/:pkg/:hostVersion/pointer", async (c) => { + const { app: appId, channel, pkg, hostVersion } = c.req.param(); + if (!isName(appId) || !isName(channel) || !isName(pkg) || !isHostVersion(hostVersion)) return fail(c, 404, "not found"); const token = await packageToken(c, storage, appId, pkg); if (!token) return fail(c, 401, "a token for this package is required"); if (!token.channels.includes(channel)) return fail(c, 403, `token is not allowed to publish to ${channel}`); const body = await json(c); - const current = await storage.getDoc(docKeys.pointer(appId, pkg, rv, channel)); + const current = await storage.getDoc(docKeys.pointer(appId, pkg, hostVersion, channel)); const version = body?.["version"] ?? current?.version; if (typeof version !== "string" || !isSegment(version)) return fail(c, 400, "version is required"); - const release = await storage.getDoc(docKeys.release(appId, pkg, rv, version)); - if (!release) return fail(c, 404, `version ${version} was never published under ${appId}/${pkg}/${rv}`); + const release = await storage.getDoc(docKeys.release(appId, pkg, hostVersion, version)); + if (!release) return fail(c, 404, `version ${version} was never published under ${appId}/${pkg}/${hostVersion}`); const rollout = parseRollout(body?.["rollout"], current && current.version === version ? current.rollout : 100); if (rollout === null) return fail(c, 400, "rollout must be an integer from 0 to 100"); const pointer: PointerDoc = { version, rollout, signature: release.signature }; - await storage.putDoc(docKeys.pointer(appId, pkg, rv, channel), pointer); + await storage.putDoc(docKeys.pointer(appId, pkg, hostVersion, channel), pointer); return c.json(pointer); }); // ---- publishing (package token), §6.1 ---- - app.put("/:app/:channel/:pkg/:rv/current.json", async (c) => { - const { app: appId, channel, pkg, rv } = c.req.param(); - if (!isName(appId) || !isName(channel) || !isName(pkg) || !isSegment(rv)) return fail(c, 404, "not found"); + app.put("/:app/:channel/:pkg/:hostVersion/current.json", async (c) => { + const { app: appId, channel, pkg, hostVersion } = c.req.param(); + if (!isName(appId) || !isName(channel) || !isName(pkg) || !isHostVersion(hostVersion)) return fail(c, 404, "not found"); const token = await packageToken(c, storage, appId, pkg); if (!token) return fail(c, 401, "a token for this package is required"); if (!token.channels.includes(channel)) return fail(c, 403, `token is not allowed to publish to ${channel}`); @@ -143,7 +143,7 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono { const rollout = parseRollout(body?.["rollout"], 100); if (rollout === null) return fail(c, 400, "rollout must be an integer from 0 to 100"); - const stored = await storage.getObject(objectKey(appId, pkg, rv, version, "manifest.json")); + const stored = await storage.getObject(objectKey(appId, pkg, hostVersion, version, "manifest.json")); if (!stored) return fail(c, 409, `${version}/manifest.json is not uploaded yet: content first, pointer last`); // the registered key, never the one inside the manifest: a token cannot bring its own key if (!(await verifySignature(registered.publicKey, stored.body, signature))) return fail(c, 400, "signature does not verify with the registered publicKey"); @@ -154,36 +154,36 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono { return fail(c, 400, (e as Error).message); } if (manifest.name !== pkg) return fail(c, 400, `manifest name ${manifest.name} is not the path package ${pkg}`); - if (manifest.runtimeVersion !== rv) return fail(c, 400, `manifest runtimeVersion ${manifest.runtimeVersion} is not the path runtime version ${rv}`); + if (manifest.hostVersion !== hostVersion) return fail(c, 400, `manifest hostVersion ${manifest.hostVersion} is not the path host version ${hostVersion}`); if (manifest.version !== version) return fail(c, 400, `manifest version ${manifest.version} is not the pointer version ${version}`); if (manifest.publicKey !== registered.publicKey) return fail(c, 400, "manifest publicKey is not the registered one"); for (const module of [...manifest.runtime, ...manifest.pages]) { const path = manifest.files[module] + ".bin"; - const info = await storage.headObject(objectKey(appId, pkg, rv, version, path)); + const info = await storage.headObject(objectKey(appId, pkg, hostVersion, version, path)); if (!info) return fail(c, 409, `${version}/${path} is not uploaded yet`); if (info.sha256 !== manifest.hashes[module]) return fail(c, 409, `${version}/${path} does not match manifest.hashes`); } - const releaseKey = docKeys.release(appId, pkg, rv, version); + const releaseKey = docKeys.release(appId, pkg, hostVersion, version); const existing = await storage.getDoc(releaseKey); const release: ReleaseRecord = { createdAt: manifest.createdAt, signature, publishedAt: existing?.publishedAt ?? new Date().toISOString() }; await storage.putDoc(releaseKey, release); const pointer: PointerDoc = { version, rollout, signature }; - await storage.putDoc(docKeys.pointer(appId, pkg, rv, channel), pointer); + await storage.putDoc(docKeys.pointer(appId, pkg, hostVersion, channel), pointer); return c.json(pointer); }); - app.put("/:app/:pkg/:rv/:version/*", async (c) => { - const { app: appId, pkg, rv, version } = c.req.param(); + app.put("/:app/:pkg/:hostVersion/:version/*", async (c) => { + const { app: appId, pkg, hostVersion, version } = c.req.param(); const path = c.req.path.split("/").slice(5).join("/"); - if (!isName(appId) || !isName(pkg) || !isSegment(rv) || !isSegment(version) || !isObjectPath(path)) return fail(c, 404, "not found"); + if (!isName(appId) || !isName(pkg) || !isHostVersion(hostVersion) || !isSegment(version) || !isObjectPath(path)) return fail(c, 404, "not found"); if (!(await packageToken(c, storage, appId, pkg))) return fail(c, 401, "a token for this package is required"); const length = Number(c.req.header("content-length") ?? "0"); if (length > maxObjectBytes) return fail(c, 413, `objects are limited to ${maxObjectBytes} bytes`); const body = await readBounded(c.req.raw.body, maxObjectBytes); if (!body) return fail(c, 413, `objects are limited to ${maxObjectBytes} bytes`); const sha256 = await sha256Hex(body); - const key = objectKey(appId, pkg, rv, version, path); + const key = objectKey(appId, pkg, hostVersion, version, path); if (await storage.putObjectIfAbsent(key, body, sha256)) return c.json({ path, sha256, existing: false }, 201); // a version never changes: the same bytes again are fine, different ones are another version's business const existing = await storage.headObject(key); @@ -193,20 +193,20 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono { // ---- delivery (public), §2 ---- - app.get("/:app/:channel/:pkg/:rv/current.json", async (c) => { - const { app: appId, channel, pkg, rv } = c.req.param(); - if (!isName(appId) || !isName(channel) || !isName(pkg) || !isSegment(rv)) return fail(c, 404, "not found"); - const pointer = await storage.getDoc(docKeys.pointer(appId, pkg, rv, channel)); + app.get("/:app/:channel/:pkg/:hostVersion/current.json", async (c) => { + const { app: appId, channel, pkg, hostVersion } = c.req.param(); + if (!isName(appId) || !isName(channel) || !isName(pkg) || !isHostVersion(hostVersion)) return fail(c, 404, "not found"); + const pointer = await storage.getDoc(docKeys.pointer(appId, pkg, hostVersion, channel)); if (!pointer) return fail(c, 404, "not found"); return c.json(pointer, 200, { "Cache-Control": "no-store" }); }); - app.get("/:app/:channel/:pkg/:rv/:version/*", async (c) => { - const { app: appId, channel, pkg, rv, version } = c.req.param(); + app.get("/:app/:channel/:pkg/:hostVersion/:version/*", async (c) => { + const { app: appId, channel, pkg, hostVersion, version } = c.req.param(); const path = c.req.path.split("/").slice(6).join("/"); - if (!isName(appId) || !isName(channel) || !isName(pkg) || !isSegment(rv) || !isSegment(version) || !isObjectPath(path)) return fail(c, 404, "not found"); + if (!isName(appId) || !isName(channel) || !isName(pkg) || !isHostVersion(hostVersion) || !isSegment(version) || !isObjectPath(path)) return fail(c, 404, "not found"); // the channel is not part of the key: one upload serves every channel that points at it - const object = await storage.streamObject(objectKey(appId, pkg, rv, version, path)); + const object = await storage.streamObject(objectKey(appId, pkg, hostVersion, version, path)); if (!object) return fail(c, 404, "not found"); return c.body(object.body, 200, { "Cache-Control": IMMUTABLE, diff --git a/src/auth.ts b/src/auth.ts index 323a3fa..67791d3 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -16,10 +16,10 @@ export const docKeys = { tokenByHash: (hash: string) => `token:${hash}`, tokenById: (app: string, pkg: string, id: string) => `tokenid:${app}:${pkg}:${id}`, // one document per version and per channel: nothing is ever read, modified and written back - release: (app: string, pkg: string, rv: string, version: string) => `${docKeys.releasePrefix(app, pkg, rv)}${version}`, - releasePrefix: (app: string, pkg: string, rv: string) => `release:${app}:${pkg}:${rv}:`, - pointer: (app: string, pkg: string, rv: string, channel: string) => `${docKeys.pointerPrefix(app, pkg, rv)}${channel}`, - pointerPrefix: (app: string, pkg: string, rv: string) => `pointer:${app}:${pkg}:${rv}:`, + release: (app: string, pkg: string, hostVersion: string, version: string) => `${docKeys.releasePrefix(app, pkg, hostVersion)}${version}`, + releasePrefix: (app: string, pkg: string, hostVersion: string) => `release:${app}:${pkg}:${hostVersion}:`, + pointer: (app: string, pkg: string, hostVersion: string, channel: string) => `${docKeys.pointerPrefix(app, pkg, hostVersion)}${channel}`, + pointerPrefix: (app: string, pkg: string, hostVersion: string) => `pointer:${app}:${pkg}:${hostVersion}:`, }; export function bearer(c: Context): string | null { diff --git a/src/ids.ts b/src/ids.ts index e016fca..886f39b 100644 --- a/src/ids.ts +++ b/src/ids.ts @@ -3,7 +3,12 @@ export function isName(value: string): boolean { return /^[a-z0-9-]+$/.test(value) && !RESERVED.has(value); } -/** `runtimeVersion` and `version`: one path segment, never `.` or `..`. */ +/** A host version: a positive integer, so an App version such as `1.2.0` cannot be mistaken for one. */ +export function isHostVersion(value: string): boolean { + return /^[1-9][0-9]*$/.test(value); +} + +/** `version`: one path segment, never `.` or `..`. */ export function isSegment(value: string): boolean { return /^[A-Za-z0-9._-]+$/.test(value) && value !== "." && value !== ".."; } diff --git a/src/manifest.ts b/src/manifest.ts index fb96ecb..49cc2ee 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -1,4 +1,4 @@ -import { isName, isObjectPath, isSegment } from "./ids.ts"; +import { isHostVersion, isName, isObjectPath, isSegment } from "./ids.ts"; import { looksLikePublicKey } from "./signature.ts"; /** The signed `/manifest.json` `tinyui bundle` wrote (tinyui docs/updates.md §1.1), the fields this server checks. */ @@ -11,7 +11,7 @@ export interface Manifest { publicKey: string; version: string; createdAt: string; - runtimeVersion: string; + hostVersion: string; } /** Throws with a message naming the first field that is not what a bundle carries. */ @@ -48,11 +48,11 @@ export function parseManifest(text: string): Manifest { publicKey: string("publicKey"), version: string("version"), createdAt: string("createdAt"), - runtimeVersion: string("runtimeVersion"), + hostVersion: string("hostVersion"), }; if (!isName(manifest.name)) throw new Error("manifest.json name is not a package name"); if (!isSegment(manifest.version)) throw new Error("manifest.json version is not a path segment"); - if (!isSegment(manifest.runtimeVersion)) throw new Error("manifest.json runtimeVersion is not a path segment"); + if (!isHostVersion(manifest.hostVersion)) throw new Error("manifest.json hostVersion is not a positive integer"); if (!looksLikePublicKey(manifest.publicKey)) throw new Error("manifest.json publicKey is not a P-256 point"); const modules = [...manifest.runtime, ...manifest.pages]; if (new Set(modules).size !== modules.length) throw new Error("manifest.json lists a module twice"); diff --git a/src/records.ts b/src/records.ts index 4adf963..8d71f6f 100644 --- a/src/records.ts +++ b/src/records.ts @@ -14,7 +14,7 @@ export interface PackageRecord { publicKeyUpdatedAt?: string; } -/** One published version under (app, pkg, rv): the pointer for any channel names one of these. */ +/** One published version under (app, pkg, hostVersion): the pointer for any channel names one of these. */ export interface ReleaseRecord { createdAt: string; signature: string; diff --git a/test/helpers.ts b/test/helpers.ts index 66ac9dc..7a4d4d5 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -20,7 +20,7 @@ export async function newKeyPair(): Promise<{ publicKey: string; privateKey: Cry export async function signedPackage(options: { name?: string; - rv?: string; + hostVersion?: string; version?: string; createdAt?: string; key?: { publicKey: string; privateKey: CryptoKey }; @@ -48,7 +48,7 @@ export async function signedPackage(options: { engine: "e".repeat(40), protocol: 1, hashes, - runtimeVersion: options.rv ?? "1", + hostVersion: options.hostVersion ?? "1", }; options.edit?.(manifest); const bytes = new TextEncoder().encode(JSON.stringify(manifest, null, 2) + "\n"); @@ -108,15 +108,15 @@ export async function setup(options: { app?: string; pkg?: string; channels?: st } /** Uploads every file and the manifest, i.e. what `tinyui publish` does before writing the pointer. */ -export async function uploadContent(app: string, pkg: string, rv: string, p: SignedPackage, token: string): Promise { +export async function uploadContent(app: string, pkg: string, hostVersion: string, p: SignedPackage, token: string): Promise { for (const [path, bytes] of Object.entries(p.files)) { - const r = await request(`/${app}/${pkg}/${rv}/${p.version}/${path}`, { method: "PUT", token, body: bytes as BodyInit }); + const r = await request(`/${app}/${pkg}/${hostVersion}/${p.version}/${path}`, { method: "PUT", token, body: bytes as BodyInit }); if (r.status !== 201 && r.status !== 200) throw new Error(`${path}: ${r.status} ${await r.text()}`); } - const m = await request(`/${app}/${pkg}/${rv}/${p.version}/manifest.json`, { method: "PUT", token, body: p.manifest as BodyInit }); + const m = await request(`/${app}/${pkg}/${hostVersion}/${p.version}/manifest.json`, { method: "PUT", token, body: p.manifest as BodyInit }); if (m.status !== 201 && m.status !== 200) throw new Error(`manifest: ${m.status} ${await m.text()}`); } -export function publishPointer(app: string, channel: string, pkg: string, rv: string, p: SignedPackage, token: string, rollout?: number): Promise { - return request(`/${app}/${channel}/${pkg}/${rv}/current.json`, { method: "PUT", token, json: { version: p.version, signature: p.signature, ...(rollout !== undefined && { rollout }) } }); +export function publishPointer(app: string, channel: string, pkg: string, hostVersion: string, p: SignedPackage, token: string, rollout?: number): Promise { + return request(`/${app}/${channel}/${pkg}/${hostVersion}/current.json`, { method: "PUT", token, json: { version: p.version, signature: p.signature, ...(rollout !== undefined && { rollout }) } }); } diff --git a/test/publish.test.ts b/test/publish.test.ts index 4e9c9b8..86d3a20 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -80,15 +80,21 @@ describe("publishing and delivery", () => { const imp = await request(`/${app}/production/${pkg}/1/current.json`, { method: "PUT", token, json: { version: impostor.version, signature: impostor.signature } }); expect(imp.status).toBe(400); - // a package for another name or runtime version cannot be published under this path + // a package for another name or host version cannot be published under this path const otherName = await signedPackage({ name: "orders", key: { publicKey: p.publicKey, privateKey: p.privateKey }, version: "v-other" }); await uploadContent(app, pkg, "1", otherName, token); const wrongName = await publishPointer(app, "production", pkg, "1", otherName, token); expect(wrongName.status).toBe(400); expect(await wrongName.text()).toContain("is not the path package"); - const otherRv = await signedPackage({ rv: "2", key: { publicKey: p.publicKey, privateKey: p.privateKey }, version: "v-rv2" }); - await uploadContent(app, pkg, "1", otherRv, token); - expect(await (await publishPointer(app, "production", pkg, "1", otherRv, token)).text()).toContain("runtimeVersion"); + const otherHostVersion = await signedPackage({ hostVersion: "2", key: { publicKey: p.publicKey, privateKey: p.privateKey }, version: "v-hv2" }); + await uploadContent(app, pkg, "1", otherHostVersion, token); + expect(await (await publishPointer(app, "production", pkg, "1", otherHostVersion, token)).text()).toContain("hostVersion"); + + // an App version is not a host version: refused in the path and in the signed manifest alike + expect((await request(`/${app}/production/${pkg}/1.2.0/current.json`, { method: "PUT", token, json: { version: p.version, signature: p.signature } })).status).toBe(404); + const semver = await signedPackage({ hostVersion: "1.2.0", key: { publicKey: p.publicKey, privateKey: p.privateKey }, version: "v-semver" }); + await uploadContent(app, pkg, "1", semver, token); + expect(await (await publishPointer(app, "production", pkg, "1", semver, token)).text()).toContain("not a positive integer"); // the pointer names one version, the manifest says another const cross = await request(`/${app}/production/${pkg}/1/current.json`, { method: "PUT", token, json: { version: "v-other", signature: p.signature } }); @@ -144,8 +150,8 @@ describe("publishing and delivery", () => { it("accepts a package tinyui bundle signed with Node's crypto", async () => { // the same fixture the Kotlin client verifies (tinyui updates/src/commonTest Fixture.kt) - const publicKey = "BC5lpOHvNqDvlsPmE+3KI2Lbr8fEh1U06S+2op5TSG5jjHvRdzk0DeXhMwsidC+i3V7YyVpwu9+qK/wUqs3hMnE="; - const signature = "MEQCIHyi+3Gh1Ocj6vFfGCXLIqB+uHBW9MUGpl9j3ME58IigAiA/G/y10Jb6ytsjG4aDb1clS4mdJBBeyv/f0fzrZ4FVbw=="; + const publicKey = "BCsQWJxiruNm+rZEwQBeGs/1nTV3QGB2TObvtD61sZlSbBGee/gnfxIWuOMCRUcrCRWGONKylaGqkcRiEq4Qy3g="; + const signature = "MEUCIHX9BKQtT7O1VUQCLj7czqmjDjWpSXQa8vcf1dZXBMC6AiEAqtfPgEn72H8PnwD9w3+wKFLe8wd3FnnyckEdtsico5A="; const version = "20260922T100000Z-abcdef1"; const manifest = [ "{", @@ -177,7 +183,7 @@ describe("publishing and delivery", () => { ' "tinyui-native": "8a65da82b504e482deb6e49382cd8b2abca80b6c6ad67ff82aa642f76c8fcc4d",', ' "shop/home": "6ca9202ab8e55afbd5f0e68113ef733655c190b1531cc7e1fc17ea3bb8d32230"', " },", - ' "runtimeVersion": "1"', + ' "hostVersion": "1"', "}", "", ].join("\n");