Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
27 changes: 26 additions & 1 deletion src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> };
}

/** 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. */
Expand Down Expand Up @@ -48,20 +58,35 @@ export function parseManifest(text: string): Manifest {
createdAt: string("createdAt"),
hostVersion: string("hostVersion"),
};
if (m["i18n"] !== undefined) {
const i18n = m["i18n"] as Record<string, unknown> | 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")) {
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
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<string, string> };
Comment thread
HarlonWang marked this conversation as resolved.
}
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");
if (instant(manifest.createdAt) === null) throw new Error("manifest.json createdAt is not a real YYYY-MM-DDTHH:MM:SSZ instant");
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");
}
Expand Down
9 changes: 8 additions & 1 deletion test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,19 @@ export async function signedPackage(options: {
/** Overrides applied to the manifest before it is serialized and signed. */
edit?: (m: Record<string, unknown>) => void;
files?: Record<string, string>;
/** language → the text of `i18n/<language>.json`; the first one is the default */
i18n?: Record<string, string>;
} = {}): Promise<SignedPackage> {
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<string, string> = { [`${name}/home`]: "pages/home" };
const hashes: Record<string, string> = {};
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<string, unknown> = {
pages: [`${name}/home`],
files: modules,
Expand All @@ -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");
Expand Down
34 changes: 34 additions & 0 deletions test/publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,40 @@ 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<string, string>)["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("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.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 () => {
const p = await signedPackage();
const { app, pkg, token } = await setup({ publicKey: p.publicKey });
Expand Down
Loading