From 7157569ad71525ffa0e2891a67d1da38a524f980 Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Fri, 25 Sep 2026 21:18:34 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20manifest=20=E7=9A=84=20i18n=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=BA=B3=E5=85=A5=E6=A0=A1=E9=AA=8C=E4=B8=8E?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=89=8D=E6=A0=B8=E5=AF=B9=EF=BC=88tinyui=20?= =?UTF-8?q?0.8.0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tinyui 0.8.0 起包里多了 i18n/<语言>.json,manifest 加 i18n 字段、hashes 也覆盖这些文件 (tinyui docs/updates.md §1.1)。此前 hashes 只许等于 pages,0.8.0 的包写指针时被 400 拒绝。 - parseManifest 接受可选 i18n,hashes 须恰好覆盖页面与 i18n 文件,i18n 路径须是文件路径 - 写指针前逐个核对页面字节码与 i18n 文件都已上传且哈希一致 Co-Authored-By: Claude Opus 5.5 --- src/app.ts | 7 +++---- src/manifest.ts | 25 ++++++++++++++++++++++++- test/helpers.ts | 9 ++++++++- test/publish.test.ts | 25 +++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/app.ts b/src/app.ts index b8144c2..51dc62c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,7 +2,7 @@ import { Hono } from "hono"; import type { Context } from "hono"; import { anyPackageToken, canManage, docKeys, isAdmin, issueAppToken, issueToken, packageToken, revokeAppToken, revokeToken } from "./auth.ts"; import { isHostVersion, isName, isObjectPath, isSegment } from "./ids.ts"; -import { instant, parseManifest } from "./manifest.ts"; +import { instant, parseManifest, payload } from "./manifest.ts"; import type { AppRecord, PackageRecord, PointerDoc, ReleaseRecord } from "./records.ts"; import { isPublicKey, verifySignature } from "./signature.ts"; import { sha256Hex, type Storage } from "./storage.ts"; @@ -208,11 +208,10 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono { 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.pages) { - const path = manifest.files[module] + ".bin"; + for (const { path, hashKey } of payload(manifest)) { 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`); + if (info.sha256 !== manifest.hashes[hashKey]) return fail(c, 409, `${version}/${path} does not match manifest.hashes`); } const pointerKey = docKeys.pointer(appId, pkg, hostVersion, channel); diff --git a/src/manifest.ts b/src/manifest.ts index 1d0cfb0..34535aa 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -11,6 +11,16 @@ export interface Manifest { version: string; createdAt: string; hostVersion: string; + /** The package's strings: language → file path inside the version directory (tinyui docs/build-chain.md §8). */ + i18n?: { default: string; files: Record }; +} + +/** Every file of the version besides `manifest.json`, with its key into `hashes`. */ +export function payload(manifest: Manifest): { path: string; hashKey: string }[] { + return [ + ...manifest.pages.map((module) => ({ path: `${manifest.files[module]}.bin`, hashKey: module })), + ...Object.values(manifest.i18n?.files ?? {}).map((path) => ({ path, hashKey: path })), + ]; } /** Throws with a message naming the first field that is not what a bundle carries. */ @@ -48,6 +58,14 @@ export function parseManifest(text: string): Manifest { createdAt: string("createdAt"), hostVersion: string("hostVersion"), }; + if (m["i18n"] !== undefined) { + const i18n = m["i18n"] as Record | null; + const files = i18n?.["files"]; + if (typeof i18n !== "object" || i18n === null || typeof i18n["default"] !== "string" || typeof files !== "object" || files === null || !Object.values(files).every((v) => typeof v === "string")) { + throw new Error("manifest.json i18n must be { default, files: { language: path } }"); + } + manifest.i18n = { default: i18n["default"], files: files as Record }; + } 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 (!isHostVersion(manifest.hostVersion)) throw new Error("manifest.json hostVersion is not a positive integer"); @@ -55,13 +73,18 @@ export function parseManifest(text: string): Manifest { if (!looksLikePublicKey(manifest.publicKey)) throw new Error("manifest.json publicKey is not a P-256 point"); const modules = manifest.pages; if (new Set(modules).size !== modules.length) throw new Error("manifest.json lists a module twice"); + const strings = Object.values(manifest.i18n?.files ?? {}); + const expected = { files: modules, hashes: [...modules, ...strings] }; for (const key of ["files", "hashes"] as const) { const keys = Object.keys(manifest[key]).sort(); - if (keys.join("\n") !== [...modules].sort().join("\n")) throw new Error(`manifest.json ${key} does not cover exactly the modules in pages`); + if (keys.join("\n") !== [...expected[key]].sort().join("\n")) throw new Error(`manifest.json ${key} does not cover exactly the modules in pages${key === "hashes" ? " and the i18n files" : ""}`); } for (const path of Object.values(manifest.files)) { if (!isObjectPath(path + ".bin")) throw new Error(`manifest.json files entry ${path} is not a file path`); } + for (const path of strings) { + if (!isObjectPath(path)) throw new Error(`manifest.json i18n file ${path} is not a file path`); + } for (const hash of Object.values(manifest.hashes)) { if (!/^[0-9a-f]{64}$/.test(hash)) throw new Error("manifest.json hashes must be sha256 hex"); } diff --git a/test/helpers.ts b/test/helpers.ts index 1bbdb83..22cfd26 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -27,15 +27,19 @@ export async function signedPackage(options: { /** Overrides applied to the manifest before it is serialized and signed. */ edit?: (m: Record) => void; files?: Record; + /** language → the text of `i18n/.json`; the first one is the default */ + i18n?: Record; } = {}): Promise { const name = options.name ?? "shop"; const version = options.version ?? "20260922T100000Z-abcdef1"; const key = options.key ?? (await newKeyPair()); - const contents = options.files ?? { "pages/home.bin": `HOME-${version}` }; + const strings = Object.fromEntries(Object.entries(options.i18n ?? {}).map(([language, text]) => [`i18n/${language}.json`, text])); + const contents = { ...(options.files ?? { "pages/home.bin": `HOME-${version}` }), ...strings }; const files = Object.fromEntries(Object.entries(contents).map(([p, text]) => [p, new TextEncoder().encode(text)])); const modules: Record = { [`${name}/home`]: "pages/home" }; const hashes: Record = {}; for (const [module, path] of Object.entries(modules)) hashes[module] = await sha256Hex(files[`${path}.bin`]!); + for (const path of Object.keys(strings)) hashes[path] = await sha256Hex(files[path]!); const manifest: Record = { pages: [`${name}/home`], files: modules, @@ -48,6 +52,9 @@ export async function signedPackage(options: { tinyui: "0.7.0", hashes, hostVersion: options.hostVersion ?? "1", + ...(options.i18n && { + i18n: { default: Object.keys(options.i18n)[0], files: Object.fromEntries(Object.keys(options.i18n).map((language) => [language, `i18n/${language}.json`])) }, + }), }; options.edit?.(manifest); const bytes = new TextEncoder().encode(JSON.stringify(manifest, null, 2) + "\n"); diff --git a/test/publish.test.ts b/test/publish.test.ts index 26cc9bb..06ea43b 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -51,6 +51,31 @@ describe("publishing and delivery", () => { expect((await request(`/${app}/production/${pkg}/1/current.json`)).status).toBe(404); }); + it("publishes a package with strings only once they are uploaded and match, and serves them", async () => { + const p = await signedPackage({ i18n: { en: '{"hi":"Hi"}', zh: '{"hi":"你好"}' } }); + const { app, pkg, token } = await setup({ publicKey: p.publicKey }); + await request(`/${app}/${pkg}/1/${p.version}/pages/home.bin`, { method: "PUT", token, body: p.files["pages/home.bin"] as BodyInit }); + await request(`/${app}/${pkg}/1/${p.version}/manifest.json`, { method: "PUT", token, body: p.manifest as BodyInit }); + const missing = await publishPointer(app, "production", pkg, "1", p, token); + expect(missing.status).toBe(409); + expect(await missing.text()).toContain("i18n/en.json is not uploaded yet"); + + await uploadContent(app, pkg, "1", p, token); + const published = await publishPointer(app, "production", pkg, "1", p, token); + expect(published.status, await published.clone().text()).toBe(200); + const zh = await request(`/${app}/production/${pkg}/1/${p.version}/i18n/zh.json`); + expect(await zh.text()).toBe('{"hi":"你好"}'); + }); + + it("refuses a manifest whose hashes leave out a string file", async () => { + const p = await signedPackage({ i18n: { en: "{}" }, edit: (m) => { delete (m["hashes"] as Record)["i18n/en.json"]; } }); + const { app, pkg, token } = await setup({ publicKey: p.publicKey }); + await uploadContent(app, pkg, "1", p, token); + const r = await publishPointer(app, "production", pkg, "1", p, token); + expect(r.status).toBe(400); + expect(await r.text()).toContain("hashes does not cover exactly the modules in pages and the i18n files"); + }); + it("keeps a version immutable: the same bytes again are fine, different ones are refused", async () => { const p = await signedPackage(); const { app, pkg, token } = await setup({ publicKey: p.publicKey }); From 77a733b9385a4352c95976af59bd89b840f018d8 Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Fri, 25 Sep 2026 21:29:13 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20i18n=20=E7=9A=84=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E5=BF=85=E9=A1=BB=E6=9C=89=E5=AF=B9=E5=BA=94?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/manifest.ts | 2 ++ test/publish.test.ts | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/src/manifest.ts b/src/manifest.ts index 34535aa..fb8e0af 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -64,6 +64,8 @@ export function parseManifest(text: string): Manifest { if (typeof i18n !== "object" || i18n === null || typeof i18n["default"] !== "string" || typeof files !== "object" || files === null || !Object.values(files).every((v) => typeof v === "string")) { throw new Error("manifest.json i18n must be { default, files: { language: path } }"); } + // the runtime falls back to the default language, so it has to be one of the files + if (!Object.hasOwn(files, i18n["default"])) throw new Error(`manifest.json i18n default ${JSON.stringify(i18n["default"])} has no file`); manifest.i18n = { default: i18n["default"], files: files as Record }; } if (!isName(manifest.name)) throw new Error("manifest.json name is not a package name"); diff --git a/test/publish.test.ts b/test/publish.test.ts index 06ea43b..1c888f6 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -76,6 +76,15 @@ describe("publishing and delivery", () => { expect(await r.text()).toContain("hashes does not cover exactly the modules in pages and the i18n files"); }); + it("refuses a default language without a file", async () => { + const p = await signedPackage({ i18n: { en: "{}" }, edit: (m) => { (m["i18n"] as { default: string }).default = "fr"; } }); + const { app, pkg, token } = await setup({ publicKey: p.publicKey }); + await uploadContent(app, pkg, "1", p, token); + const r = await publishPointer(app, "production", pkg, "1", p, token); + expect(r.status).toBe(400); + expect(await r.text()).toContain('i18n default "fr" has no file'); + }); + it("keeps a version immutable: the same bytes again are fine, different ones are refused", async () => { const p = await signedPackage(); const { app, pkg, token } = await setup({ publicKey: p.publicKey }); From 17597d4b0d74f6d9b01a7826295ab583c92922f9 Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Fri, 25 Sep 2026 21:29:47 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test:=20=E9=BB=98=E8=AE=A4=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E7=BC=BA=E6=96=87=E4=BB=B6=E7=9A=84=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E8=AF=BB=20JSON=20=E7=9A=84=20error=20=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- test/publish.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/publish.test.ts b/test/publish.test.ts index 1c888f6..f60edd1 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -82,7 +82,7 @@ describe("publishing and delivery", () => { await uploadContent(app, pkg, "1", p, token); const r = await publishPointer(app, "production", pkg, "1", p, token); expect(r.status).toBe(400); - expect(await r.text()).toContain('i18n default "fr" has no file'); + expect(((await r.json()) as { error: string }).error).toContain('i18n default "fr" has no file'); }); it("keeps a version immutable: the same bytes again are fine, different ones are refused", async () => {