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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ All requests carry `Authorization: Bearer <token>`.
| `PUT /<app>/<pkg>/<hostVersion>/<version>/<path>` | upload one file (idempotent; different bytes at an existing path → 409) |
| `PUT /<app>/<channel>/<pkg>/<hostVersion>/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 /<app>/<pkg>/<hostVersion>/releases` | published versions and where each channel points |
| `POST /<app>/<channel>/<pkg>/<hostVersion>/pointer` with `{ version?, rollout? }` | rollback, promotion and rollout changes are the same operation |
| `POST /<app>/<channel>/<pkg>/<hostVersion>/pointer` with `{ version?, rollout? }` | promotion and rollout changes; the pointer only moves to a newer version (a rollback is a new version with the old content), 409 otherwise |

`tinyui publish` / `tinyui releases …` in `tinyui-cli` are thin clients of these endpoints.

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
| `PUT /<app>/<pkg>/<hostVersion>/<version>/<path>` | 上传一个文件(幂等;同路径不同内容 → 409) |
| `PUT /<app>/<channel>/<pkg>/<hostVersion>/current.json`,body `{ version, signature, rollout? }` | 发布:用登记的公钥对已上传的 `manifest.json` 原始字节验签,核对 `name` / `hostVersion` / `version` / `publicKey` 与每个文件的 sha256,记录 release,切指针 |
| `GET /<app>/<pkg>/<hostVersion>/releases` | 已发布的版本与各 channel 的指针 |
| `POST /<app>/<channel>/<pkg>/<hostVersion>/pointer`,body `{ version?, rollout? }` | 回滚、晋级、改灰度是同一个操作 |
| `POST /<app>/<channel>/<pkg>/<hostVersion>/pointer`,body `{ version?, rollout? }` | 晋级、改灰度;指针只往更新的版本走(回滚是用旧内容发一个新版本),否则 409 |

`tinyui-cli` 的 `tinyui publish` / `tinyui releases …` 是这些端点的薄封装。

Expand Down
34 changes: 30 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 { parseManifest } from "./manifest.ts";
import { instant, parseManifest } 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 @@ -160,15 +160,19 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono {
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<PointerDoc>(docKeys.pointer(appId, pkg, hostVersion, channel));
const pointerKey = docKeys.pointer(appId, pkg, hostVersion, channel);
const at = await storage.getDocRevision<PointerDoc>(pointerKey);
const current = at?.value ?? null;
const version = body?.["version"] ?? current?.version;
if (typeof version !== "string" || !isSegment(version)) return fail(c, 400, "version is required");
const release = await storage.getDoc<ReleaseRecord>(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 backwards = await movesBack(storage, appId, pkg, hostVersion, current, version, release.createdAt);
Comment thread
HarlonWang marked this conversation as resolved.
if (backwards) return fail(c, 409, backwards);
const pointer: PointerDoc = { version, rollout, signature: release.signature };
await storage.putDoc(docKeys.pointer(appId, pkg, hostVersion, channel), pointer);
if (!(await storage.putDocIf(pointerKey, pointer, at?.revision ?? null))) return fail(c, 409, MOVED_MEANWHILE);
return c.json(pointer);
});

Expand Down Expand Up @@ -211,12 +215,17 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono {
if (info.sha256 !== manifest.hashes[module]) return fail(c, 409, `${version}/${path} does not match manifest.hashes`);
}

const pointerKey = docKeys.pointer(appId, pkg, hostVersion, channel);
const at = await storage.getDocRevision<PointerDoc>(pointerKey);
const current = at?.value ?? null;
const backwards = await movesBack(storage, appId, pkg, hostVersion, current, version, manifest.createdAt);
if (backwards) return fail(c, 409, backwards);
const releaseKey = docKeys.release(appId, pkg, hostVersion, version);
const existing = await storage.getDoc<ReleaseRecord>(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, hostVersion, channel), pointer);
if (!(await storage.putDocIf(pointerKey, pointer, at?.revision ?? null))) return fail(c, 409, `${version} is recorded and can be promoted, but ${MOVED_MEANWHILE}`);
return c.json(pointer);
});

Expand Down Expand Up @@ -303,3 +312,20 @@ function parseRollout(value: unknown, fallback: number): number | null {
if (value === undefined) return fallback;
return Number.isInteger(value) && (value as number) >= 0 && (value as number) <= 100 ? (value as number) : null;
}

/**
* Why pointing [version] would move the channel back, or null. Pointers only move forward: a rollback
* is a new version with the old content (docs/updates.md §6.2), so every device can reach it.
*/
async function movesBack(storage: Storage, app: string, pkg: string, hostVersion: string, current: PointerDoc | null, version: string, createdAt: string): Promise<string | null> {
if (!current || current.version === version) return null;
const at = await storage.getDoc<ReleaseRecord>(docKeys.release(app, pkg, hostVersion, current.version));
if (!at) return `the channel points at ${current.version}, which has no release record; refusing to move it`;
const from = instant(at.createdAt);
const to = instant(createdAt);
if (from !== null && to !== null && to > from) return null;
return `the channel is at ${current.version} (${at.createdAt}); ${version} (${createdAt}) is not newer. Pointers only move forward: publish the old content as a new version`;
}

/** The pointer changed between the forward-only check and the write: another publish or promotion won. */
const MOVED_MEANWHILE = "the channel pointer changed meanwhile; look at it again and retry";
9 changes: 9 additions & 0 deletions src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export function parseManifest(text: string): Manifest {
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.runtime, ...manifest.pages];
if (new Set(modules).size !== modules.length) throw new Error("manifest.json lists a module twice");
Expand All @@ -68,3 +69,11 @@ export function parseManifest(text: string): Manifest {
}
return manifest;
}

/** Milliseconds of a `YYYY-MM-DDTHH:MM:SSZ` that names a real instant, as `tinyui build` writes it; null otherwise. */
export function instant(value: string): number | null {
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(value)) return null;
const ms = Date.parse(value);
// Date.parse rolls 2026-02-30 over into March: only a round trip proves the fields were in range
return Number.isNaN(ms) || new Date(ms).toISOString() !== value.replace("Z", ".000Z") ? null : ms;
}
29 changes: 29 additions & 0 deletions src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import type { Env } from "./env.ts";
export interface Storage {
getDoc<T>(key: string): Promise<T | null>;
putDoc(key: string, value: unknown): Promise<void>;
/** A document with the revision [putDocIf] compares against. */
getDocRevision<T>(key: string): Promise<{ value: T; revision: string } | null>;
/** Writes only while the document is still at [revision] (null: still absent); false when it moved meanwhile. */
putDocIf(key: string, value: unknown, revision: string | null): Promise<boolean>;
deleteDoc(key: string): Promise<void>;
/** Keys starting with [prefix]. */
listDocs(prefix: string): Promise<string[]>;
Expand Down Expand Up @@ -48,6 +52,17 @@ export class R2Storage implements Storage {
await this.bucket.put(`docs/${key}`, JSON.stringify(value), { httpMetadata: { contentType: "application/json" } });
}

async getDocRevision<T>(key: string): Promise<{ value: T; revision: string } | null> {
const object = await this.bucket.get(`docs/${key}`);
return object ? { value: (await object.json()) as T, revision: object.etag } : null;
}

async putDocIf(key: string, value: unknown, revision: string | null): Promise<boolean> {
const onlyIf = revision === null ? { etagDoesNotMatch: "*" } : { etagMatches: revision };
const written = await this.bucket.put(`docs/${key}`, JSON.stringify(value), { httpMetadata: { contentType: "application/json" }, onlyIf });
return written !== null;
}

async deleteDoc(key: string): Promise<void> {
await this.bucket.delete(`docs/${key}`);
}
Expand Down Expand Up @@ -100,6 +115,7 @@ export class R2Storage implements Storage {
/** In-process storage for tests and for running the app outside Cloudflare. */
export class MemoryStorage implements Storage {
private readonly docs = new Map<string, string>();
private readonly revisions = new Map<string, number>();
private readonly objects = new Map<string, StoredObject>();

async getDoc<T>(key: string): Promise<T | null> {
Expand All @@ -109,6 +125,19 @@ export class MemoryStorage implements Storage {

async putDoc(key: string, value: unknown): Promise<void> {
this.docs.set(key, JSON.stringify(value));
this.revisions.set(key, (this.revisions.get(key) ?? 0) + 1);
}

async getDocRevision<T>(key: string): Promise<{ value: T; revision: string } | null> {
const value = this.docs.get(key);
return value === undefined ? null : { value: JSON.parse(value) as T, revision: String(this.revisions.get(key)) };
}

async putDocIf(key: string, value: unknown, revision: string | null): Promise<boolean> {
const at = this.docs.has(key) ? String(this.revisions.get(key)) : null;
if (at !== revision) return false;
await this.putDoc(key, value);
return true;
}

async deleteDoc(key: string): Promise<void> {
Expand Down
48 changes: 41 additions & 7 deletions test/publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ describe("publishing and delivery", () => {
expect((await publishPointer(app, "staging", pkg, "1", p, token)).status).toBe(200);
});

it("promotes, rolls back and re-rolls out without re-uploading or re-signing", async () => {
it("promotes and re-rolls out without re-uploading or re-signing; pointers never move back", async () => {
const v1 = await signedPackage({ version: "v1", createdAt: "2026-09-22T10:00:00Z" });
const key = { publicKey: v1.publicKey, privateKey: v1.privateKey };
const v2 = await signedPackage({ version: "v2", createdAt: "2026-09-22T11:00:00Z", key });
Expand All @@ -134,18 +134,52 @@ describe("publishing and delivery", () => {
const wider = await request(`/${app}/production/${pkg}/1/pointer`, { method: "POST", token, json: { rollout: 100 } });
expect(await wider.json()).toEqual({ version: "v2", rollout: 100, signature: v2.signature });

// rollback is the same operation pointing back
const rollback = await request(`/${app}/production/${pkg}/1/pointer`, { method: "POST", token, json: { version: "v1" } });
expect(await rollback.json()).toEqual({ version: "v1", rollout: 100, signature: v1.signature });
expect(await (await request(`/${app}/production/${pkg}/1/current.json`)).json()).toEqual({ version: "v1", rollout: 100, signature: v1.signature });
// a rollback is a new version, never a pointer moved back: neither promote nor publish goes backwards
const back = await request(`/${app}/production/${pkg}/1/pointer`, { method: "POST", token, json: { version: "v1" } });
expect(back.status).toBe(409);
expect(await back.text()).toContain("only move forward");
expect((await publishPointer(app, "staging", pkg, "1", v1, token)).status).toBe(409);
expect((await publishPointer(app, "staging", pkg, "1", v2, token)).status, "the same version again is not a move").toBe(200);
expect(await (await request(`/${app}/production/${pkg}/1/current.json`)).json()).toEqual({ version: "v2", rollout: 100, signature: v2.signature });

expect((await request(`/${app}/production/${pkg}/1/pointer`, { method: "POST", token, json: { version: "v9" } })).status).toBe(404);
expect((await request(`/${app}/production/${pkg}/1/pointer`, { method: "POST", token, json: { version: "v1", rollout: 101 } })).status).toBe(400);
expect((await request(`/${app}/production/${pkg}/1/pointer`, { method: "POST", token, json: { version: "v2", rollout: 101 } })).status).toBe(400);

const releases = await request(`/${app}/${pkg}/1/releases`, { token });
const listed = (await releases.json()) as { versions: { version: string }[]; channels: Record<string, { version: string; rollout: number }> };
expect(listed.versions.map((v) => v.version)).toEqual(["v2", "v1"]);
expect(listed.channels).toEqual({ staging: { version: "v2", rollout: 100 }, production: { version: "v1", rollout: 100 } });
expect(listed.channels).toEqual({ staging: { version: "v2", rollout: 100 }, production: { version: "v2", rollout: 100 } });
});

it("never leaves a pointer behind a published version, even when publishes race", async () => {
const v1 = await signedPackage({ version: "r1", createdAt: "2026-09-22T10:00:00Z" });
const key = { publicKey: v1.publicKey, privateKey: v1.privateKey };
const v2 = await signedPackage({ version: "r2", createdAt: "2026-09-22T11:00:00Z", key });
const v3 = await signedPackage({ version: "r3", createdAt: "2026-09-22T12:00:00Z", key });
const { app, pkg, token } = await setup({ publicKey: v1.publicKey });
for (const p of [v1, v2, v3]) await uploadContent(app, pkg, "1", p, token);
expect((await publishPointer(app, "staging", pkg, "1", v1, token)).status).toBe(200);

const [r2, r3] = await Promise.all([publishPointer(app, "staging", pkg, "1", v2, token), publishPointer(app, "staging", pkg, "1", v3, token)]);
const landed = (await (await request(`/${app}/staging/${pkg}/1/current.json`)).json()) as { version: string };
const accepted = [r2.status === 200 && "r2", r3.status === 200 && "r3"].filter(Boolean) as string[];
expect(accepted.length).toBeGreaterThan(0);
// whatever raced, the pointer is at the newest version any request was told it published
expect(landed.version).toBe(accepted.sort().at(-1));
for (const r of [r2, r3]) expect([200, 409]).toContain(r.status);
});

it("refuses a createdAt that is not one real instant in the one form", async () => {
const p = await signedPackage({ createdAt: "2026-09-22T10:00:00.500Z" });
const { app, pkg, token } = await setup({ publicKey: p.publicKey });
await uploadContent(app, pkg, "1", p, token);
const response = await publishPointer(app, "staging", pkg, "1", p, token);
expect(response.status).toBe(400);
expect(await response.text()).toContain("createdAt is not a real YYYY-MM-DDTHH:MM:SSZ instant");

const impossible = await signedPackage({ version: "impossible", createdAt: "2026-02-30T10:00:00Z", key: { publicKey: p.publicKey, privateKey: p.privateKey } });
await uploadContent(app, pkg, "1", impossible, token);
expect((await publishPointer(app, "staging", pkg, "1", impossible, token)).status).toBe(400);
});

it("accepts a package tinyui bundle signed with Node's crypto", async () => {
Expand Down
Loading