From da135682bf371efba0822c02644750ad962116c0 Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Thu, 24 Sep 2026 10:12:25 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=8C=87=E9=92=88=E5=8F=AA=E5=BE=80?= =?UTF-8?q?=E5=89=8D=E8=B5=B0=EF=BC=9A=E5=8F=91=E5=B8=83=E4=B8=8E=E6=99=8B?= =?UTF-8?q?=E7=BA=A7=E9=83=BD=E6=8B=92=E7=BB=9D=E6=8C=87=E5=90=91=E6=AF=94?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E6=9B=B4=E6=97=A7=E7=9A=84=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回滚改为用旧内容发一个新版本,所有设备(含内置了坏版本的新装用户)都能收到。 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- README.zh-CN.md | 2 +- src/app.ts | 16 ++++++++++++++++ test/publish.test.ts | 17 ++++++++++------- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 952f0ee..7b16cb8 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ All requests carry `Authorization: Bearer `. | `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 | +| `POST /////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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 0dc9806..af645d6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -28,7 +28,7 @@ | `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? }` | 回滚、晋级、改灰度是同一个操作 | +| `POST /////pointer`,body `{ version?, rollout? }` | 晋级、改灰度;指针只往更新的版本走(回滚是用旧内容发一个新版本),否则 409 | `tinyui-cli` 的 `tinyui publish` / `tinyui releases …` 是这些端点的薄封装。 diff --git a/src/app.ts b/src/app.ts index 7ba9b9a..4d47702 100644 --- a/src/app.ts +++ b/src/app.ts @@ -167,6 +167,8 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono { 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); + 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); return c.json(pointer); @@ -211,6 +213,9 @@ 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 current = await storage.getDoc(docKeys.pointer(appId, pkg, hostVersion, channel)); + 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(releaseKey); const release: ReleaseRecord = { createdAt: manifest.createdAt, signature, publishedAt: existing?.publishedAt ?? new Date().toISOString() }; @@ -303,3 +308,14 @@ 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 { + if (!current || current.version === version) return null; + const at = await storage.getDoc(docKeys.release(app, pkg, hostVersion, current.version)); + if (!at || createdAt > at.createdAt) 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`; +} diff --git a/test/publish.test.ts b/test/publish.test.ts index 909ba9c..b1c1154 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -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 }); @@ -134,18 +134,21 @@ 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 }; 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("accepts a package tinyui bundle signed with Node's crypto", async () => { From 2cc1ef6090c82e0e7449e50d746d67f17aba0c50 Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Thu, 24 Sep 2026 10:30:26 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E6=84=8F=E8=A7=81?= =?UTF-8?q?=EF=BC=9A=E6=8C=87=E9=92=88=E7=9A=84=E5=89=8D=E8=BF=9B=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E4=B8=8E=E5=86=99=E5=85=A5=E6=94=B9=E4=B8=BA=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E5=86=99=E5=85=A5=EF=BC=88etag=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E6=97=B6=E5=90=8E=E5=88=B0=E8=80=85=20409?= =?UTF-8?q?=EF=BC=9BcreatedAt=20=E9=99=90=E5=AE=9A=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E4=BD=BF=E5=AD=97=E7=AC=A6=E4=B8=B2=E6=AF=94?= =?UTF-8?q?=E8=BE=83=E5=8D=B3=E6=97=B6=E9=97=B4=E5=85=88=E5=90=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/app.ts | 15 +++++++++++---- src/manifest.ts | 2 ++ src/storage.ts | 29 +++++++++++++++++++++++++++++ test/publish.test.ts | 27 +++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/app.ts b/src/app.ts index 4d47702..96951ee 100644 --- a/src/app.ts +++ b/src/app.ts @@ -160,7 +160,9 @@ 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(docKeys.pointer(appId, pkg, hostVersion, channel)); + const pointerKey = docKeys.pointer(appId, pkg, hostVersion, channel); + const at = await storage.getDocRevision(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(docKeys.release(appId, pkg, hostVersion, version)); @@ -170,7 +172,7 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono { const backwards = await movesBack(storage, appId, pkg, hostVersion, current, version, release.createdAt); 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); }); @@ -213,7 +215,9 @@ 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 current = await storage.getDoc(docKeys.pointer(appId, pkg, hostVersion, channel)); + const pointerKey = docKeys.pointer(appId, pkg, hostVersion, channel); + const at = await storage.getDocRevision(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); @@ -221,7 +225,7 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono { 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, MOVED_MEANWHILE); return c.json(pointer); }); @@ -319,3 +323,6 @@ async function movesBack(storage: Storage, app: string, pkg: string, hostVersion if (!at || createdAt > at.createdAt) 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"; diff --git a/src/manifest.ts b/src/manifest.ts index 49cc2ee..c762f19 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -53,6 +53,8 @@ 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"); + // one fixed form, so comparing the strings is comparing the instants (pointers only move forward) + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(manifest.createdAt)) throw new Error("manifest.json createdAt is not YYYY-MM-DDTHH:MM:SSZ"); 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/storage.ts b/src/storage.ts index 1dd8249..d64478e 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -8,6 +8,10 @@ import type { Env } from "./env.ts"; export interface Storage { getDoc(key: string): Promise; putDoc(key: string, value: unknown): Promise; + /** A document with the revision [putDocIf] compares against. */ + getDocRevision(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; deleteDoc(key: string): Promise; /** Keys starting with [prefix]. */ listDocs(prefix: string): Promise; @@ -48,6 +52,17 @@ export class R2Storage implements Storage { await this.bucket.put(`docs/${key}`, JSON.stringify(value), { httpMetadata: { contentType: "application/json" } }); } + async getDocRevision(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 { + 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 { await this.bucket.delete(`docs/${key}`); } @@ -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(); + private readonly revisions = new Map(); private readonly objects = new Map(); async getDoc(key: string): Promise { @@ -109,6 +125,19 @@ export class MemoryStorage implements Storage { async putDoc(key: string, value: unknown): Promise { this.docs.set(key, JSON.stringify(value)); + this.revisions.set(key, (this.revisions.get(key) ?? 0) + 1); + } + + async getDocRevision(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 { + 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 { diff --git a/test/publish.test.ts b/test/publish.test.ts index b1c1154..8c8f69f 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -151,6 +151,33 @@ describe("publishing and delivery", () => { 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 does not sort as it reads", 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 YYYY-MM-DDTHH:MM:SSZ"); + }); + 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 = "BCsQWJxiruNm+rZEwQBeGs/1nTV3QGB2TObvtD61sZlSbBGee/gnfxIWuOMCRUcrCRWGONKylaGqkcRiEq4Qy3g="; From bcee978e95913d4fb98a79bc437acb698304a97d Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Thu, 24 Sep 2026 10:53:42 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E6=84=8F=E8=A7=81?= =?UTF-8?q?=EF=BC=9AcreatedAt=20=E9=A1=BB=E6=98=AF=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E5=B9=B6=E6=8C=89=E6=97=B6=E9=97=B4=E6=88=B3?= =?UTF-8?q?=E6=AF=94=E8=BE=83=EF=BC=8C=E8=A7=A3=E6=9E=90=E4=B8=8D=E4=BA=86?= =?UTF-8?q?=E7=9A=84=E6=97=A7=E8=AE=B0=E5=BD=95=E4=B8=8D=E6=94=BE=E8=A1=8C?= =?UTF-8?q?=EF=BC=9B=E5=8F=91=E5=B8=83=E6=97=B6=E6=8C=87=E9=92=88=E5=86=B2?= =?UTF-8?q?=E7=AA=81=E7=9A=84=20409=20=E8=AF=B4=E6=98=8E=20release=20?= =?UTF-8?q?=E5=B7=B2=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/app.ts | 9 ++++++--- src/manifest.ts | 11 +++++++++-- test/publish.test.ts | 8 ++++++-- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/app.ts b/src/app.ts index 96951ee..fc3f54b 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 { 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"; @@ -225,7 +225,7 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono { const release: ReleaseRecord = { createdAt: manifest.createdAt, signature, publishedAt: existing?.publishedAt ?? new Date().toISOString() }; await storage.putDoc(releaseKey, release); const pointer: PointerDoc = { version, rollout, signature }; - if (!(await storage.putDocIf(pointerKey, pointer, at?.revision ?? null))) return fail(c, 409, MOVED_MEANWHILE); + 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); }); @@ -320,7 +320,10 @@ function parseRollout(value: unknown, fallback: number): number | null { async function movesBack(storage: Storage, app: string, pkg: string, hostVersion: string, current: PointerDoc | null, version: string, createdAt: string): Promise { if (!current || current.version === version) return null; const at = await storage.getDoc(docKeys.release(app, pkg, hostVersion, current.version)); - if (!at || createdAt > at.createdAt) return null; + if (!at) return null; + 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`; } diff --git a/src/manifest.ts b/src/manifest.ts index c762f19..bd8fd43 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -53,8 +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"); - // one fixed form, so comparing the strings is comparing the instants (pointers only move forward) - if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(manifest.createdAt)) throw new Error("manifest.json createdAt is not YYYY-MM-DDTHH:MM:SSZ"); + 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"); @@ -70,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; +} diff --git a/test/publish.test.ts b/test/publish.test.ts index 8c8f69f..afa0cfe 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -169,13 +169,17 @@ describe("publishing and delivery", () => { for (const r of [r2, r3]) expect([200, 409]).toContain(r.status); }); - it("refuses a createdAt that does not sort as it reads", async () => { + 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 YYYY-MM-DDTHH:MM:SSZ"); + 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 () => { From 05f34e25c896ccee4240731e4b9d41d2c4d5d6a6 Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Thu, 24 Sep 2026 11:01:10 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E6=84=8F=E8=A7=81?= =?UTF-8?q?=EF=BC=9A=E5=BD=93=E5=89=8D=E6=8C=87=E9=92=88=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=20release=20=E8=AE=B0=E5=BD=95=E6=97=B6=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/app.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index fc3f54b..e146cd8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -320,7 +320,7 @@ function parseRollout(value: unknown, fallback: number): number | null { async function movesBack(storage: Storage, app: string, pkg: string, hostVersion: string, current: PointerDoc | null, version: string, createdAt: string): Promise { if (!current || current.version === version) return null; const at = await storage.getDoc(docKeys.release(app, pkg, hostVersion, current.version)); - if (!at) return null; + 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;